triage-graph

Multi-agent incident triage on LangGraph. Specialists investigate an alert, a proposer picks one action from a fixed allow-list, and nothing runs until a human approves, edits or rejects it.

An agent that can restart services needs a stop that no prompt can talk its way past. Here the allow-list lives in one module and every layer checks it again, and the pause for a human is a checkpoint on disk that survives the process being killed. The same investigation was rebuilt in CrewAI to compare the two frameworks from experience.

It is a portfolio project: four synthetic incidents, a deterministic fake model, and a dry-run adapter that only records what it would do. It has never been used on a real incident.

Plays the six stages below in order, about 55 seconds. Scroll or press Esc to stop.

Language
Python 3.11+
Tests
134 passing, 1 skipped: the paid real-model eval
Graph
LangGraph 1.2.12, checkpoints in SQLite
Model
A deterministic fake by default. The Anthropic path is written but has never been run.
Actions
A dry run: recorded, never performed
Comparison
A CrewAI port of the investigation and proposal
License
MIT
uv run triage run --alert fixtures/alerts/high-latency.json --thread-id demo proposer proposes scale_up on checkout-api (proposal 8bedf438cdeb) approval paused: waiting for a human decision === Waiting for approval (thread demo) ===

Real output from the CLI on the bundled fixtures, captured for this page with the fake model. Some investigation lines are cut where marked. Proposal ids are random on every run. Illustrative input The thread ids, the reason and the approver name were typed for the capture.

1Send specialists, one step at a time

A supervisor node calls the model with a route tool and sends one specialist at a time: a log investigator, a metrics investigator or a runbook agent. Each specialist is its own small LangGraph subgraph, with private messages and at most four tool rounds.

  • Every supervisor turn that doesn't end the investigation uses a step. A reply with no valid route call uses one too, so a model that never answers properly still stops.
  • At the limit, 6 by default, the run ends as escalated and goes straight to the report. It never reaches the approval gate, so nothing is executed.

LangGraph's own recursion limit is no guard here. Its docs say 1000; the installed source says 10007. The repo sets it to twice the step limit plus 10, just above its own check.

The same alert with 6 steps, then with 2

Step lines and model-call counts are real output from triage run on the high-latency fixture, with --max-steps 6 and --max-steps 2.

2Read the logs and flag the metrics that moved

The tools read fixture files that stand in for log and metrics backends. The LangGraph graph and the CrewAI port wrap the same functions, so both see the same evidence.

  • Log lines are grouped by message template, with numbers and ids masked, so 44 lines become 4 groups with counts and first and last seen.
  • A metric counts as anomalous only if its peak is at least 1.5 times its baseline and it also rose by a fixed amount: 15 points of CPU or memory, 150 ms of p99 latency, 1 point of error rate, or 50 requests a second.

The fixed amount stops a tiny baseline from turning noise into an anomaly. Here the error rate peaked at 3.2 times its baseline and still didn't count, because it only rose by 0.44 points.

checkout-api, the hours before the alert

Logs, WARN and above, the hour before the alert

    Metrics: peak against the hour before the window

    Peak divided by baselineThe 1.5x ratio
    Log groups, baselines, peaks and rises come from the project's own search_logs and detect_metric_anomalies, run on the high-latency fixture with the windows the investigators used.

    3Search the runbooks for this service

    The runbook agent searches the markdown runbooks with TF-IDF, counting headings twice. It passes the alerting service, so only that service's runbook and the general policy are searched.

    Every runbook section ends with a suggested action, and each incident's matching section suggests the expected answer. The repo says so, and its eval has a variant that hides those lines. Lexical search is the weak spot it admits: without the service filter, the right section for the ambiguous incident only narrowly wins.

    The ambiguous incident: bursts of 5xx errors on orders-api

    Queryorders-api 5xx error rate above 1% orders-api crossed a 1% 5xx rate several times in the last 30 minutes; each burst lasts about two minutes.

    Scores re-run offline with the project's own search_runbooks on the bundled runbooks, with the exact query the runbook agent sent. Top three results in each mode.

    4Propose one action from the allow-list

    The proposer returns a diagnosis and one action. Code then checks it against actions.py, whose first line reads “This module, not any prompt, decides what may run.”

    • An action off the list, or an allowed action aimed at another service, is not passed on. It becomes page_human, and what the model asked for is recorded so the human sees the attempt.
    • The proposal id is created here, before the pause, because LangGraph re-runs the paused node from the top when it resumes.

    What the model asked for, and what the human is shown

    The allow-listrestart_servicescale_uprollback_deploypage_human
    The first row is the fake model on the high-latency fixture. The other two are the repo's scripted misbehaving models from tests/scripted.py, run through the real graph on the high-latency and intermittent-5xx fixtures. The notes are the program's own text.

    5Stop and wait for a human

    The approval node calls interrupt() with the proposal and the allow-list. The run is saved to SQLite and the command exits. The decision can come hours later, from another shell, with triage resume.

    The node accepts only a well-formed decision that names this proposal. Anything else ends the run as refused, with nothing executed. A rejected or refused run can't be resumed; a person starts a new one, which is why the CLI checks an edit before it sends it.

    One paused proposal, six possible decisions

    Paused at the gate

    ALERT-1042, checkout-apiProposal 8bedf438cdebscale_up on checkout-api

    The decision

    From the CLI

    The approval node checks

    1. A well-formed decisionno extra fields; only an edit names an action
    2. Names the pending proposalthe id the human was shown
    3. An edit stays on the allow-listchecked again here, not only by the CLI
    4. Approve or edita reject ends the run here

    Past the line

    dry_run_ledger.jsonl

    Approve, edit, reject and the off-list edit are real CLI sessions on the high-latency fixture, each on its own run, so the proposal ids differ. The CLI can't send the last two values; they went through the repo's runner, as its tests do, and the lines shown are the program's output. Illustrative input The reason, the approver name and the wrong proposal id were typed.

    ClearedEverything above reads fixture files and changes nothing. Below this line, an approved decision reaches the executor, and the executor only records what it would do.

    6Check again, then act once

    The executor doesn't trust the route that led to it. authorize() re-checks the saved decision from scratch, and only then calls the dry-run adapter.

    • The decision must approve or edit this proposal id, the action must be on the allow-list, and the target must be the alerting service. If any check fails it raises instead of acting.
    • The adapter has one method per allowed action and appends one line to a ledger. If the same proposal id comes back after a crash, it returns the first record instead of acting twice.
    • The report is built by code from the saved state, not written by a model.

    Nothing is restarted, scaled or rolled back: every method returns “would …” text. The gate also trusts the checkpoint file. Anyone who can write to it could forge a decision, because decisions aren't signed; the README lists this as a limitation.

    After the gate, two ways it can go

    Process 1triage run … --waitmemory-leak alert, proposal 9e269d82445f, then waits on stdinkill -9, exit status -9, no ledger file
    SQLite checkpointParked at the approval node12 model calls, 8,688 tokens in, 721 outnothing lost
    Process 2triage resume crash --approvea new process on the same file12 calls, 8,688 in, 721 out: no step re-ran
    1. Saved decisionPresent and well-formed
    2. Approve or editA reject never gets here
    3. Same proposalThe id matches
    4. Allow-listThe final action
    5. Alerting serviceThe target
    6. Dry-run adapterOne ledger line per proposal
    Real sequence, mirroring tests/test_crash_resume.py: kill -9 while triage run --wait waits for a decision, then triage resume in a new process. Like the test, both ran as python -m triage_graph.cli with a temporary --state-dir, left out here. Model-call and token counts were read from the checkpoint before and after. The forged-state errors are the program's exceptions, verbatim.

    The report, written by code

    Every run ends with a markdown report built from the saved state: the outcome, the model, the steps, the human's decision, what the executor recorded, and every tool call with what it returned. This is the report from the Approve tab above. The log and runbook evidence is cut here; the full file has it.

    
              

    What it is careful about

    Token counts are labelled
    The fake model's usage is characters divided by four, and the report says “estimated” rather than passing it off as real.
    The model is recorded
    The graph stores which model produced the findings, so a run resumed under another provider still reports the right one.
    Refusals are kept
    When the proposer refuses what the model asked for, the note goes into the report, so the reviewer sees the attempt.

    Built twice: LangGraph and CrewAI

    The investigation and the proposal were rebuilt as a CrewAI crew, with the same fixtures, tools, prompts and allow-list check. The rows below quote the repo's written comparison. Both versions were only ever run on fake models, so this says nothing about how either behaves with a real one.

    LangGraph 1.2.12CrewAI 1.15.23
    Where control flow lives“In the graph: nodes, conditional edges”“Mostly in the model: a delegating agent picks coworkers through tool calls”
    Step limit“A counter in state, checked by the supervisor node; ends with a clean escalated outcome”“max_iter ends with a forced final answer and no signal; took a tool hook plus a ConditionalTask”
    Human approval“interrupt() inside any node; resume with any JSON value”“Crews: a blocking input() review loop. Flows: @human_feedback with a pending state; resume with a string”
    Crash recovery“Checkpoint after every step; exact resume, tested with SIGKILL”“Checkpoint per finished task. Restore skips finished tasks, but custom LLMs and task conditions come back wrong”
    Writes outside the repo“Nothing”“A key file under the user data dir on import; a SQLite file there on every kickoff; telemetry on by default”

    The verdict, in the write-up's words

    “For this workflow, LangGraph. Its main requirements are a hard approval gate, a step limit with a clear outcome, and a pause that survives a crash, and in LangGraph those are ordinary code at points I choose.”

    “CrewAI was quicker for describing agents and their tools, and delegation needed no routing code.”

    Read the comparison

    Checked for this page

    The port proposed the same action as the LangGraph version on all four alerts, with 12 model calls each:

    rollback_deployscale_uprestart_servicepage_human

    The repo's tests pin the restore problem: after a crash, the custom LLM came back as an OpenAI client and the conditional task as a plain task. They passed.

    Running the port also wrote a third file the docs didn't list: a version cache, written after CrewAI asked pypi.org for its latest release. The port now turns that check off, as it does CrewAI's telemetry, and a test makes sure it stays off.

    Measured, and what wasn't

    Every result in the repo comes from the fake model. No real-model run has been made, and the README says so in bold. These numbers were re-run for this page.

    The fake eval, re-run

    Four alerts, six runs each, in two variants: with the runbooks' suggested actions, and with them hidden from every agent.

    With suggestions 24 of 24Suggestions hidden 24 of 24

    That is by construction: the fake model's rules were written against these fixtures, and the eval report opens by saying so. Its tables matched the committed report exactly.

    Cost, planned before spending

    Estimated cost per run of the eval, against a $2 cap.

    Haiku 4.5$0.036
    Sonnet 5.5, low$0.087
    Opus 5.5, low$0.174
    Opus 5.5, medium$0.294

    The line is $0.25, the most a run can cost for one run of each of the 8 cells to fit under $2. The planner fits 6, 2 and 1 runs per cell, and refuses the last. Without a key it prints the plan and stops. A callback refuses any call whose worst case could cross the cap.

    Docs against source

    Two places where LangGraph's docs and its installed source disagree, both re-checked in langgraph 1.2.12:

    The default recursion limit is documented as 1000 and set to 10007 (_internal/_config.py:32).

    Command(resume=None) crashes with UnboundLocalError, reported upstream as langgraph issue #7034. The runner refuses None before LangGraph sees it, and the thread stays paused.

    134 tests pass, 1 is skipped

    The skipped test is the real-model eval. It spends money, so it runs only when both an API key and TRIAGE_EVAL_REAL=1 are set. The other 134 run on the fake model with no key.

      Weighted toward the gate

      • The gate: the run pauses with nothing executed, a forged approval in the input is ignored, six malformed or mismatched decisions fail closed, and the executor refuses six forged states.
      • A real crash: the CLI is killed while it waits for a decision, resumed from a new process, and checked for exactly one ledger line and an unchanged token count.
      • The graph: the step limit, replies with no route call, off-list and other-service proposals, the tool-round cap and the input schema.
      • The CrewAI port: the same action as LangGraph on every alert, the step limit under parallel delegation, and what a checkpoint restore loses.
      • The docs: a test checks that the README's diagram has exactly the graph's edges.

      CI runs ruff, the tests and the README quickstart on Ubuntu and macOS with Python 3.11, 3.12 and 3.13, with no secrets.

      Deliberately missing

      Signed decisions
      The gate trusts the checkpoint database, so anyone who can write to it can forge a decision. An HMAC over the proposal id and decision would close that; it is out of scope.
      Acting on other services
      Actions only ever target the alerting service. A fix that belongs to a failing dependency always goes to a person.
      Real-model results
      The Anthropic path and the real-model eval were built and tested offline, but never run.
      Real backends
      Four synthetic incidents, one service each. The tools read files, and the runbooks name the answer.
      More than one user
      A local CLI over a SQLite file, with no authentication and no guard against two people resuming the same run. The ledger does refuse to record a proposal twice.

      Run it

      git clone https://github.com/ahmed-hashim-pro/triage-graph.git
      cd triage-graph
      uv sync
      
      # Investigates, then stops at the gate. No key needed.
      uv run triage run --alert fixtures/alerts/high-latency.json --thread-id demo
      
      # A separate process decides.
      uv run triage resume demo --approve
      uv run triage show demo

      Try --reject --reason "..." or --edit restart_service instead, or --wait to decide in the same terminal. The CrewAI port needs uv sync --extra crewai, then uv run python -m crewai_port --alert fixtures/alerts/error-spike-after-deploy.json.