From 104fbc45e427f3f919cbe758715cdacd296f5b0d Mon Sep 17 00:00:00 2001 From: yshsh218-crypto Date: Tue, 21 Jul 2026 16:56:02 +0300 Subject: [PATCH] Add files via upload --- kits/AGENT/README.md | 255 ++++++++++ kits/AGENT/agent-failure-investigator.zip | Bin 0 -> 62534 bytes .../agent-failure-investigator/README.md | 255 ++++++++++ .../agent-failure-investigator/bench/forge.js | 169 +++++++ .../bench/results.json | 32 ++ .../agent-failure-investigator/bench/run.js | 69 +++ .../bench/scale-results.json | 37 ++ .../agent-failure-investigator/bench/scale.js | 93 ++++ .../agent-failure-investigator/css/style.css | 442 ++++++++++++++++++ .../docs/adapters.md | 38 ++ .../docs/architecture.svg | 162 +++++++ .../docs/trace-schema.md | 39 ++ .../agent-failure-investigator/index.html | 164 +++++++ .../agent-failure-investigator/js/adapters.js | 268 +++++++++++ .../agent-failure-investigator/js/advisor.js | 50 ++ .../agent-failure-investigator/js/app.js | 436 +++++++++++++++++ .../agent-failure-investigator/js/compare.js | 58 +++ .../agent-failure-investigator/js/engine.js | 128 +++++ .../agent-failure-investigator/js/exporter.js | 91 ++++ .../agent-failure-investigator/js/graph.js | 56 +++ .../agent-failure-investigator/js/report.js | 120 +++++ .../agent-failure-investigator/js/traces.js | 267 +++++++++++ .../agent-failure-investigator/rules/core.js | 64 +++ .../rules/hallucination.js | 42 ++ .../rules/prompt.js | 57 +++ .../agent-failure-investigator/rules/rag.js | 60 +++ .../rules/tool_failure.js | 57 +++ .../rules/wrong_tool.js | 60 +++ .../tests/run-tests.js | 146 ++++++ kits/AGENT/architecture.svg | 162 +++++++ 30 files changed, 3877 insertions(+) create mode 100644 kits/AGENT/README.md create mode 100644 kits/AGENT/agent-failure-investigator.zip create mode 100644 kits/AGENT/agent-failure-investigator/README.md create mode 100644 kits/AGENT/agent-failure-investigator/bench/forge.js create mode 100644 kits/AGENT/agent-failure-investigator/bench/results.json create mode 100644 kits/AGENT/agent-failure-investigator/bench/run.js create mode 100644 kits/AGENT/agent-failure-investigator/bench/scale-results.json create mode 100644 kits/AGENT/agent-failure-investigator/bench/scale.js create mode 100644 kits/AGENT/agent-failure-investigator/css/style.css create mode 100644 kits/AGENT/agent-failure-investigator/docs/adapters.md create mode 100644 kits/AGENT/agent-failure-investigator/docs/architecture.svg create mode 100644 kits/AGENT/agent-failure-investigator/docs/trace-schema.md create mode 100644 kits/AGENT/agent-failure-investigator/index.html create mode 100644 kits/AGENT/agent-failure-investigator/js/adapters.js create mode 100644 kits/AGENT/agent-failure-investigator/js/advisor.js create mode 100644 kits/AGENT/agent-failure-investigator/js/app.js create mode 100644 kits/AGENT/agent-failure-investigator/js/compare.js create mode 100644 kits/AGENT/agent-failure-investigator/js/engine.js create mode 100644 kits/AGENT/agent-failure-investigator/js/exporter.js create mode 100644 kits/AGENT/agent-failure-investigator/js/graph.js create mode 100644 kits/AGENT/agent-failure-investigator/js/report.js create mode 100644 kits/AGENT/agent-failure-investigator/js/traces.js create mode 100644 kits/AGENT/agent-failure-investigator/rules/core.js create mode 100644 kits/AGENT/agent-failure-investigator/rules/hallucination.js create mode 100644 kits/AGENT/agent-failure-investigator/rules/prompt.js create mode 100644 kits/AGENT/agent-failure-investigator/rules/rag.js create mode 100644 kits/AGENT/agent-failure-investigator/rules/tool_failure.js create mode 100644 kits/AGENT/agent-failure-investigator/rules/wrong_tool.js create mode 100644 kits/AGENT/agent-failure-investigator/tests/run-tests.js create mode 100644 kits/AGENT/architecture.svg diff --git a/kits/AGENT/README.md b/kits/AGENT/README.md new file mode 100644 index 000000000..80d24d653 --- /dev/null +++ b/kits/AGENT/README.md @@ -0,0 +1,255 @@ +# Agent Failure Investigator + +**A forensic diagnostic tool for AI agents.** Upload a failed agent trace — from **LangGraph, OpenAI Agents SDK, CrewAI, AutoGen, Lamatic, or the native format** — and get a structured failure report with clickable evidence, a failure-propagation graph, a reconstructed timeline, a remediation playbook, and an exportable investigation document. + +``` +Upload trace.json → Auto-detect format → Convert → Investigate +``` + +| Supported input formats | | +|---|---| +| ✓ LangGraph | `astream_events` export | +| ✓ OpenAI Agents SDK | trace + spans export | +| ✓ CrewAI | crew/tasks/tool-usage export | +| ✓ AutoGen | chat history export | +| ✓ Lamatic | flow execution nodes | +| ✓ Native | `docs/trace-schema.md` | + +Every adapter maps a framework's run log onto one canonical trace schema; the rule engine never knows which framework the trace came from. + +Every AI team asks the same question: *why did the agent fail?* Today the answer means reading logs manually, re-reading prompts manually, and diffing tool calls manually. Agent Failure Investigator automates that investigation. + +``` +===================== FAILURE REPORT ===================== +Failure Category TOOL FAILURE +Confidence 87% (evidence-weighted, capped at 95) +Evidence [R11] book_table timed out after 10000ms + [R13] Fallback branch activated after the failure +Root Cause The failure began in infrastructure, not in the + model. The flow continued to generation as if the + tool had succeeded, converting an infrastructure + error into a user-facing false statement. +Contributing Factors Hallucination (+30) — unsupported claims in answer +Recommended Fixes Add retries with backoff; make fallback honest +Preventive Actions Alert on p95 tool latency; add circuit breaker +========================================================== +``` + +## Why the design is deterministic-first + +The core of this tool is **not** an LLM. It is a **deterministic rule engine** (`rules/`): 13 pure functions over the trace, each producing evidence with references into the raw data, each contributing weighted points to one of five failure categories: + +| Category | Detected by (examples) | +|---|---| +| Hallucination | R14 tool output ignored · R22 claims unsupported by any source | +| Tool Failure | R11 timeout · R12 error · R13 fallback after failure | +| Prompt Ambiguity | R31 contradictory instructions · R32 vague quantifiers · R33 output instability | +| Wrong Tool Selection | R41 better-matching tool available · R42 no re-plan after irrelevant result | +| RAG Failure | R21 empty retrieval before factual answer · R23 low relevance scores · R25 no retry | + +The category with the most evidence points becomes the primary **Failure Category**; confidence is computed from evidence weight (`35 + 0.8 × points`, capped at 95 — an investigator reports evidence, never certainty). The engine also performs **conflict resolution**: root-cause rules suppress their downstream symptoms, so "the agent ignored the wrong tool's output" is attributed to *Wrong Tool Selection*, not misdiagnosed as hallucination. + +The LLM enters only at the last step, and only optionally: it rewrites the Root Cause section as fluent prose from the engine's findings. It receives the fired rules as fixed facts and cannot add, remove, or reweigh evidence. An investigator that hallucinated its own findings would be useless — **diagnosis is deterministic; language is not.** + +## Architecture + +![Architecture — data flow from raw trace to investigation report](docs/architecture.svg) + +The pipeline in one sentence: a raw framework export is claimed and translated by an adapter into one canonical trace schema; 13 pure rules produce evidence with references into the raw data; the engine resolves conflicts, weighs evidence into a category and a confidence, and reconstructs the timeline; the report layer turns that verdict into an investigation document, a propagation graph, a remediation playbook, and exports — with LLM narration as a strictly optional last-mile step that can rephrase but never decide. + +
+Text version of the diagram (for terminal readers) + +``` + trace.json (any supported framework) + │ + ▼ +┌──────────────────────┐ +│ Format auto-detect │ js/adapters.js — 6 adapters, each with +│ + Adapter │ claim(doc) and translate(doc) +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ Normalizer │ canonical trace schema (docs/trace-schema.md) +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ Rule Engine │ rules/ — one plugin file per failure class, +│ (pluggable) │ each rule: pure test(trace) → evidence + refs +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ Conflict Resolver │ js/engine.js — root causes suppress symptoms +│ + Evidence Builder │ scoring, confidence, timeline reconstruction +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ Report Generator │ js/report.js + js/advisor.js + js/exporter.js +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ UI │ propagation graph · ECG timeline · evidence +│ │ · remediation playbook · A/B compare · export +└──────────────────────┘ +``` + +
+ +The rule engine is a plugin system: dropping a new file into `rules/` that calls `definePlugin({ id, category, points, title, fix, prevention, test })` adds a detector — no engine change, no build step. + +``` +rules/ + core.js categories, registry, shared text analysis + hallucination.js R14 · R22 + tool_failure.js R11 · R12 · R13 + rag.js R21 · R23 · R25 + prompt.js R31 · R32 · R33 + wrong_tool.js R41 · R42 +``` + +## Quickstart + +No build step, no dependencies, no API key required. + +``` +git clone +open index.html # or just double-click it +``` + +Pick one of the five preloaded cases (one per failure category) and press **Run investigation** — or press **Upload trace.json** and drop in a raw export from LangGraph, OpenAI Agents SDK, CrewAI, AutoGen, or Lamatic; the format is detected and converted automatically. You can also paste a native-schema trace as JSON (schema in `docs/trace-schema.md`). + +To run the regression tests and the benchmark: + +``` +node tests/run-tests.js +node bench/run.js 100 # accuracy benchmark (any N: 100 / 1000 / 10000) +node bench/scale.js # latency vs trace size (100 / 1,000 / 10,000 events) +``` + +The suite asserts that all five sample cases classify into their expected category, that a healthy trace produces **no** diagnosis, that confidence stays in range, that every piece of evidence carries well-formed references into the raw trace, that each of the six adapters detects and converts its fixture correctly, and that the comparison and remediation layers behave. + +## Benchmarks + +Two benchmarks, both seeded and reproducible on any machine with `node` (numbers below: Node v22, single core, no warm cluster — this runs in a browser tab, so single-threaded cold performance is the honest measurement). + +### 1 — Accuracy at dataset scale (`bench/run.js`) + +`bench/run.js N` forges a seeded, labeled dataset of *N* traces across all five failure classes plus healthy runs (~28% adversarially mutated to sit near rule thresholds) and measures the engine against the labels. The class mix is proportional, so results are comparable across scales: + +| Traces | Accuracy | False positives | False negatives | Misclassified | Avg / trace | p95 / trace | +|---:|---:|---:|---:|---:|---:|---:| +| 100 | 95.0% | 0.0% | 0.0% | 5.0% | 0.25 ms | 0.76 ms | +| 1,000 | 94.4% | 0.0% | 0.0% | 5.6% | 0.12 ms | 0.14 ms | +| 10,000 | 94.1% | 0.0% | 0.0% | 5.9% | 0.08 ms | 0.10 ms | + +Accuracy is stable across scales (the ±1% drift is the adversarial mutation rate playing out over more samples, not degradation), and **no healthy trace is ever flagged and no failure is ever missed entirely** at any scale — every error is a wrong *category*, not a wrong *verdict*. Per-category recall at N=100: Tool Failure 100% · Hallucination 100% · Prompt Ambiguity 100% · RAG Failure 89% · Wrong Tool 77%. The misses are by construction — mutated traces where relevance scores hover at the threshold or the query genuinely overlaps both tools — and they end up misrouted to a *neighboring* category rather than missed. (Avg time per trace *decreasing* with N is JIT warm-up, not magic.) + +### 2 — Latency vs trace size (`bench/scale.js`) + +Real agent runs are not 8 events long. `bench/scale.js` takes 60 labeled traces, pads each with diagnostically neutral traffic (INFO logs, intermediate assistant turns, heartbeat tool calls — the noise a long-running agent actually produces) up to a target size, then measures the **full investigation** (rules + conflict resolution + scoring + timeline reconstruction) and asserts the verdict is identical to the verdict on the un-padded trace: + +| Events / trace | Avg | p50 | p95 | Max | Throughput | Verdicts stable | +|---:|---:|---:|---:|---:|---:|---:| +| 100 | 0.24 ms | 0.17 ms | 0.61 ms | 0.79 ms | ~4,200 traces/s | 60/60 | +| 1,000 | 1.47 ms | 0.96 ms | 5.85 ms | 6.39 ms | ~680 traces/s | 60/60 | +| 10,000 | 11.83 ms | 9.03 ms | 32.85 ms | 85.98 ms | ~85 traces/s | 60/60 | + +Two takeaways: latency grows **near-linearly** with trace size (rules are single-pass over the event arrays), and **classification is invariant to trace size** — a 10,000-event trace with the same failure buried inside gets the same diagnosis as an 8-event one, at every size, in every run. A 10,000-event investigation still completes in ~12 ms — fast enough to run on every failed production run, not just sampled ones. + +Both benchmarks regenerate deterministically: + +``` +node bench/run.js 10000 # → bench/results.json +node bench/scale.js # → bench/scale-results.json (100 / 1,000 / 10,000 events) +node bench/scale.js 100 50000 # custom sizes +``` + +## Explainability graph & remediation + +Every investigation renders a failure-propagation graph — `User → Prompt → Planner → Retriever → Tool → LLM → Answer` — with the stage that originated the failure highlighted in red and every downstream stage shown as contaminated. Below the fixes, a **remediation playbook** turns the diagnosis into concrete engineering patterns (retry policy, circuit breaker, groundedness gate, query reformulation, mutually-exclusive tool specs, …), each with a one-line rationale. + +## Compare two runs + +The **Compare** panel takes a Trace A (before the fix) and a Trace B (after), runs both investigations, and reports which rules were resolved, which persist, which are new, plus deltas on category, confidence, tool errors, and total tool latency — a regression test for agent fixes. + +## Export + +**Export Markdown** downloads the full investigation (timeline, evidence, root cause, recommendations, prevention, remediation playbook) as a `.md` file; **Export PDF** opens a print-formatted document ready to save as PDF. + +## Optional LLM narration + +Paste an Anthropic API key into the report panel and press **Compose with Claude** to have the Root Cause narrated as prose. The key lives in memory only and is never stored. If the call fails, the tool silently keeps the rule-based narrative — the report never depends on network access. + +## What's in a trace + +A trace is a single JSON object capturing one agent run (full schema with field-by-field docs in `docs/trace-schema.md`): + +```json +{ + "system_prompt": "...", + "conversation": [{ "role": "user", "ts": "10:33:01", "content": "..." }], + "available_tools":[{ "name": "...", "description": "..." }], + "tool_calls": [{ "ts": "...", "tool": "...", "input": {}, "status": "success|error|timeout", "duration_ms": 0, "output": "..." }], + "retrieved_docs": [{ "id": "...", "source": "...", "score": 0.0, "content": "..." }], + "logs": [{ "ts": "...", "level": "INFO|WARN|ERROR", "event": "...", "message": "..." }], + "final_response": { "ts": "...", "content": "..." } +} +``` + +This shape deliberately mirrors what agent platforms already log: the request lifecycle from prompt to response, with tools, retrieval, and infrastructure events in between. + +## Future Lamatic Integration + +This tool was designed so that its input maps one-to-one onto data Lamatic already has. A native integration would: + +* **Read Flow Logs** — pull a failed run directly from Lamatic's real-time logs and traces instead of pasting JSON, using the flow's request lifecycle as the trace. +* **Analyze Tool Calls** — map node executions (tool nodes, timeouts, retries, fallback branches) onto the rule engine's tool-failure and wrong-tool rules. +* **Analyze Retrieval** — read vector search results and relevance scores from the RAG nodes to power the RAG-failure and groundedness rules. +* **Generate Failure Report** — attach the failure report to the run in the Logs view, so "why did this run fail?" is answered where the run lives, and *Recommended Fixes* link straight to the node that needs editing in the flow builder. + +The rule catalog is intentionally a plain array: teams could ship their own rules per flow (e.g., "a booking confirmation must contain a confirmation id") the same way they ship prompts. + +## Repository layout + +``` +index.html app shell (zero build step) +css/style.css diagnostic console theme +rules/ pluggable rule engine — one file per failure class +js/adapters.js format auto-detection + 6 framework adapters +js/engine.js rule runner, scoring, conflict resolution, timeline +js/graph.js failure-propagation graph +js/advisor.js remediation playbooks +js/exporter.js Markdown / PDF investigation export +js/compare.js before/after trace comparison +js/report.js report composer (offline) + optional Claude narration +js/app.js UI: import, case picker, ECG timeline, report, viewer +js/traces.js 5 sample failure cases +bench/forge.js seeded trace synthesizer (+ neutral-event inflation) +bench/run.js accuracy benchmark at dataset scale +bench/scale.js latency-vs-trace-size benchmark +docs/architecture.svg architecture diagram (embedded above) +docs/trace-schema.md canonical trace format documentation +tests/run-tests.js regression tests (node, no dependencies) +``` + +## Known Limitations & Future Work + +This system is **deterministic by design** — and that design has known, deliberate limits. Stating them precisely matters more than overstating what 13 rules can do. + +### What the current system cannot do + +* **Lexical, not semantic.** The groundedness and tool-affinity rules operate on token overlap and pattern shapes. Token overlap is not entailment, and a regex is not a semantic claim-checker: *"the parcel reached its destination"* and *"delivered"* are the same fact to a human and different strings to R22. Paraphrased hallucinations that reuse the source's vocabulary can slip through; correct answers phrased in fresh vocabulary can be flagged as unsupported. +* **Rule coverage is finite.** The engine detects the five failure classes it has rules for. Failure modes outside the catalog — looping/planning pathologies, context-window truncation, multi-agent coordination failures, prompt injection — currently produce either no diagnosis or a partial one. +* **Confidence is evidence weight, not calibrated probability.** `35 + 0.8 × points (cap 95)` is a transparent, monotone score — it is *not* a statistically calibrated probability. An 87 does not mean "correct 87% of the time"; it means "a lot of independent evidence fired." +* **The benchmark is synthetic.** The forged dataset (including its adversarial mutations) is generated by the same author as the rules. 94–95% accuracy on it demonstrates internal consistency and regression safety, not real-world recall. Numbers on production traces will be lower and messier — the benchmark's job is to keep the engine honest as it evolves, not to be a leaderboard claim. +* **Single-trace scope.** The engine diagnoses one run at a time. It does not yet aggregate across runs ("this tool times out on 4% of traffic, always between 14:00–15:00") — the fleet-level view is where the per-trace verdicts become operationally decisive. + +Consequently: **the report is a strong lead for an engineer, not a verdict.** Every rule is cheap, explainable and auditable — that is the point of the design — but an investigator's output should be read as evidence, which is exactly why confidence is capped at 95. + +### Planned next steps + +1. **Semantic layer behind the same rule interface.** Embedding-based groundedness (claim ↔ source cosine similarity) and semantic tool-affinity as *additional* rules registered through the same `definePlugin` contract. The architecture anticipates this: rules already return evidence + refs, so a semantic rule slots in without touching the engine. The deterministic rules stay as the cheap, auditable first pass; embeddings become a second opinion, not a replacement. +2. **LLM-as-rule, sandboxed like the narrator.** For claim verification that embeddings can't settle, an optional LLM-backed rule whose output is constrained to the same `{ evidence, refs }` shape — subject to the same invariant that stochastic components may add *candidate* evidence but the deterministic scorer still decides. +3. **Calibration.** Once real labeled traces exist, fit the confidence mapping to observed precision (isotonic regression is enough) so 80 means 80. +4. **Cross-run aggregation.** Persist verdicts and mine them: recurring rule patterns per flow, tool-latency trends, regression detection tied into the existing A/B compare. +5. **Per-flow custom rules.** The rule catalog is a plain array precisely so teams can ship domain rules ("a booking confirmation must contain a confirmation id") the way they ship prompts. diff --git a/kits/AGENT/agent-failure-investigator.zip b/kits/AGENT/agent-failure-investigator.zip new file mode 100644 index 0000000000000000000000000000000000000000..a7300dea62612d063506bbfefa4cd62d3d7c48d2 GIT binary patch literal 62534 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ઌ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�s5gsS1+F&Q4@) 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 literal 0 HcmV?d00001 diff --git a/kits/AGENT/agent-failure-investigator/README.md b/kits/AGENT/agent-failure-investigator/README.md new file mode 100644 index 000000000..80d24d653 --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/README.md @@ -0,0 +1,255 @@ +# Agent Failure Investigator + +**A forensic diagnostic tool for AI agents.** Upload a failed agent trace — from **LangGraph, OpenAI Agents SDK, CrewAI, AutoGen, Lamatic, or the native format** — and get a structured failure report with clickable evidence, a failure-propagation graph, a reconstructed timeline, a remediation playbook, and an exportable investigation document. + +``` +Upload trace.json → Auto-detect format → Convert → Investigate +``` + +| Supported input formats | | +|---|---| +| ✓ LangGraph | `astream_events` export | +| ✓ OpenAI Agents SDK | trace + spans export | +| ✓ CrewAI | crew/tasks/tool-usage export | +| ✓ AutoGen | chat history export | +| ✓ Lamatic | flow execution nodes | +| ✓ Native | `docs/trace-schema.md` | + +Every adapter maps a framework's run log onto one canonical trace schema; the rule engine never knows which framework the trace came from. + +Every AI team asks the same question: *why did the agent fail?* Today the answer means reading logs manually, re-reading prompts manually, and diffing tool calls manually. Agent Failure Investigator automates that investigation. + +``` +===================== FAILURE REPORT ===================== +Failure Category TOOL FAILURE +Confidence 87% (evidence-weighted, capped at 95) +Evidence [R11] book_table timed out after 10000ms + [R13] Fallback branch activated after the failure +Root Cause The failure began in infrastructure, not in the + model. The flow continued to generation as if the + tool had succeeded, converting an infrastructure + error into a user-facing false statement. +Contributing Factors Hallucination (+30) — unsupported claims in answer +Recommended Fixes Add retries with backoff; make fallback honest +Preventive Actions Alert on p95 tool latency; add circuit breaker +========================================================== +``` + +## Why the design is deterministic-first + +The core of this tool is **not** an LLM. It is a **deterministic rule engine** (`rules/`): 13 pure functions over the trace, each producing evidence with references into the raw data, each contributing weighted points to one of five failure categories: + +| Category | Detected by (examples) | +|---|---| +| Hallucination | R14 tool output ignored · R22 claims unsupported by any source | +| Tool Failure | R11 timeout · R12 error · R13 fallback after failure | +| Prompt Ambiguity | R31 contradictory instructions · R32 vague quantifiers · R33 output instability | +| Wrong Tool Selection | R41 better-matching tool available · R42 no re-plan after irrelevant result | +| RAG Failure | R21 empty retrieval before factual answer · R23 low relevance scores · R25 no retry | + +The category with the most evidence points becomes the primary **Failure Category**; confidence is computed from evidence weight (`35 + 0.8 × points`, capped at 95 — an investigator reports evidence, never certainty). The engine also performs **conflict resolution**: root-cause rules suppress their downstream symptoms, so "the agent ignored the wrong tool's output" is attributed to *Wrong Tool Selection*, not misdiagnosed as hallucination. + +The LLM enters only at the last step, and only optionally: it rewrites the Root Cause section as fluent prose from the engine's findings. It receives the fired rules as fixed facts and cannot add, remove, or reweigh evidence. An investigator that hallucinated its own findings would be useless — **diagnosis is deterministic; language is not.** + +## Architecture + +![Architecture — data flow from raw trace to investigation report](docs/architecture.svg) + +The pipeline in one sentence: a raw framework export is claimed and translated by an adapter into one canonical trace schema; 13 pure rules produce evidence with references into the raw data; the engine resolves conflicts, weighs evidence into a category and a confidence, and reconstructs the timeline; the report layer turns that verdict into an investigation document, a propagation graph, a remediation playbook, and exports — with LLM narration as a strictly optional last-mile step that can rephrase but never decide. + +

+Text version of the diagram (for terminal readers) + +``` + trace.json (any supported framework) + │ + ▼ +┌──────────────────────┐ +│ Format auto-detect │ js/adapters.js — 6 adapters, each with +│ + Adapter │ claim(doc) and translate(doc) +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ Normalizer │ canonical trace schema (docs/trace-schema.md) +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ Rule Engine │ rules/ — one plugin file per failure class, +│ (pluggable) │ each rule: pure test(trace) → evidence + refs +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ Conflict Resolver │ js/engine.js — root causes suppress symptoms +│ + Evidence Builder │ scoring, confidence, timeline reconstruction +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ Report Generator │ js/report.js + js/advisor.js + js/exporter.js +└──────────┬───────────┘ + ▼ +┌──────────────────────┐ +│ UI │ propagation graph · ECG timeline · evidence +│ │ · remediation playbook · A/B compare · export +└──────────────────────┘ +``` + +
+ +The rule engine is a plugin system: dropping a new file into `rules/` that calls `definePlugin({ id, category, points, title, fix, prevention, test })` adds a detector — no engine change, no build step. + +``` +rules/ + core.js categories, registry, shared text analysis + hallucination.js R14 · R22 + tool_failure.js R11 · R12 · R13 + rag.js R21 · R23 · R25 + prompt.js R31 · R32 · R33 + wrong_tool.js R41 · R42 +``` + +## Quickstart + +No build step, no dependencies, no API key required. + +``` +git clone +open index.html # or just double-click it +``` + +Pick one of the five preloaded cases (one per failure category) and press **Run investigation** — or press **Upload trace.json** and drop in a raw export from LangGraph, OpenAI Agents SDK, CrewAI, AutoGen, or Lamatic; the format is detected and converted automatically. You can also paste a native-schema trace as JSON (schema in `docs/trace-schema.md`). + +To run the regression tests and the benchmark: + +``` +node tests/run-tests.js +node bench/run.js 100 # accuracy benchmark (any N: 100 / 1000 / 10000) +node bench/scale.js # latency vs trace size (100 / 1,000 / 10,000 events) +``` + +The suite asserts that all five sample cases classify into their expected category, that a healthy trace produces **no** diagnosis, that confidence stays in range, that every piece of evidence carries well-formed references into the raw trace, that each of the six adapters detects and converts its fixture correctly, and that the comparison and remediation layers behave. + +## Benchmarks + +Two benchmarks, both seeded and reproducible on any machine with `node` (numbers below: Node v22, single core, no warm cluster — this runs in a browser tab, so single-threaded cold performance is the honest measurement). + +### 1 — Accuracy at dataset scale (`bench/run.js`) + +`bench/run.js N` forges a seeded, labeled dataset of *N* traces across all five failure classes plus healthy runs (~28% adversarially mutated to sit near rule thresholds) and measures the engine against the labels. The class mix is proportional, so results are comparable across scales: + +| Traces | Accuracy | False positives | False negatives | Misclassified | Avg / trace | p95 / trace | +|---:|---:|---:|---:|---:|---:|---:| +| 100 | 95.0% | 0.0% | 0.0% | 5.0% | 0.25 ms | 0.76 ms | +| 1,000 | 94.4% | 0.0% | 0.0% | 5.6% | 0.12 ms | 0.14 ms | +| 10,000 | 94.1% | 0.0% | 0.0% | 5.9% | 0.08 ms | 0.10 ms | + +Accuracy is stable across scales (the ±1% drift is the adversarial mutation rate playing out over more samples, not degradation), and **no healthy trace is ever flagged and no failure is ever missed entirely** at any scale — every error is a wrong *category*, not a wrong *verdict*. Per-category recall at N=100: Tool Failure 100% · Hallucination 100% · Prompt Ambiguity 100% · RAG Failure 89% · Wrong Tool 77%. The misses are by construction — mutated traces where relevance scores hover at the threshold or the query genuinely overlaps both tools — and they end up misrouted to a *neighboring* category rather than missed. (Avg time per trace *decreasing* with N is JIT warm-up, not magic.) + +### 2 — Latency vs trace size (`bench/scale.js`) + +Real agent runs are not 8 events long. `bench/scale.js` takes 60 labeled traces, pads each with diagnostically neutral traffic (INFO logs, intermediate assistant turns, heartbeat tool calls — the noise a long-running agent actually produces) up to a target size, then measures the **full investigation** (rules + conflict resolution + scoring + timeline reconstruction) and asserts the verdict is identical to the verdict on the un-padded trace: + +| Events / trace | Avg | p50 | p95 | Max | Throughput | Verdicts stable | +|---:|---:|---:|---:|---:|---:|---:| +| 100 | 0.24 ms | 0.17 ms | 0.61 ms | 0.79 ms | ~4,200 traces/s | 60/60 | +| 1,000 | 1.47 ms | 0.96 ms | 5.85 ms | 6.39 ms | ~680 traces/s | 60/60 | +| 10,000 | 11.83 ms | 9.03 ms | 32.85 ms | 85.98 ms | ~85 traces/s | 60/60 | + +Two takeaways: latency grows **near-linearly** with trace size (rules are single-pass over the event arrays), and **classification is invariant to trace size** — a 10,000-event trace with the same failure buried inside gets the same diagnosis as an 8-event one, at every size, in every run. A 10,000-event investigation still completes in ~12 ms — fast enough to run on every failed production run, not just sampled ones. + +Both benchmarks regenerate deterministically: + +``` +node bench/run.js 10000 # → bench/results.json +node bench/scale.js # → bench/scale-results.json (100 / 1,000 / 10,000 events) +node bench/scale.js 100 50000 # custom sizes +``` + +## Explainability graph & remediation + +Every investigation renders a failure-propagation graph — `User → Prompt → Planner → Retriever → Tool → LLM → Answer` — with the stage that originated the failure highlighted in red and every downstream stage shown as contaminated. Below the fixes, a **remediation playbook** turns the diagnosis into concrete engineering patterns (retry policy, circuit breaker, groundedness gate, query reformulation, mutually-exclusive tool specs, …), each with a one-line rationale. + +## Compare two runs + +The **Compare** panel takes a Trace A (before the fix) and a Trace B (after), runs both investigations, and reports which rules were resolved, which persist, which are new, plus deltas on category, confidence, tool errors, and total tool latency — a regression test for agent fixes. + +## Export + +**Export Markdown** downloads the full investigation (timeline, evidence, root cause, recommendations, prevention, remediation playbook) as a `.md` file; **Export PDF** opens a print-formatted document ready to save as PDF. + +## Optional LLM narration + +Paste an Anthropic API key into the report panel and press **Compose with Claude** to have the Root Cause narrated as prose. The key lives in memory only and is never stored. If the call fails, the tool silently keeps the rule-based narrative — the report never depends on network access. + +## What's in a trace + +A trace is a single JSON object capturing one agent run (full schema with field-by-field docs in `docs/trace-schema.md`): + +```json +{ + "system_prompt": "...", + "conversation": [{ "role": "user", "ts": "10:33:01", "content": "..." }], + "available_tools":[{ "name": "...", "description": "..." }], + "tool_calls": [{ "ts": "...", "tool": "...", "input": {}, "status": "success|error|timeout", "duration_ms": 0, "output": "..." }], + "retrieved_docs": [{ "id": "...", "source": "...", "score": 0.0, "content": "..." }], + "logs": [{ "ts": "...", "level": "INFO|WARN|ERROR", "event": "...", "message": "..." }], + "final_response": { "ts": "...", "content": "..." } +} +``` + +This shape deliberately mirrors what agent platforms already log: the request lifecycle from prompt to response, with tools, retrieval, and infrastructure events in between. + +## Future Lamatic Integration + +This tool was designed so that its input maps one-to-one onto data Lamatic already has. A native integration would: + +* **Read Flow Logs** — pull a failed run directly from Lamatic's real-time logs and traces instead of pasting JSON, using the flow's request lifecycle as the trace. +* **Analyze Tool Calls** — map node executions (tool nodes, timeouts, retries, fallback branches) onto the rule engine's tool-failure and wrong-tool rules. +* **Analyze Retrieval** — read vector search results and relevance scores from the RAG nodes to power the RAG-failure and groundedness rules. +* **Generate Failure Report** — attach the failure report to the run in the Logs view, so "why did this run fail?" is answered where the run lives, and *Recommended Fixes* link straight to the node that needs editing in the flow builder. + +The rule catalog is intentionally a plain array: teams could ship their own rules per flow (e.g., "a booking confirmation must contain a confirmation id") the same way they ship prompts. + +## Repository layout + +``` +index.html app shell (zero build step) +css/style.css diagnostic console theme +rules/ pluggable rule engine — one file per failure class +js/adapters.js format auto-detection + 6 framework adapters +js/engine.js rule runner, scoring, conflict resolution, timeline +js/graph.js failure-propagation graph +js/advisor.js remediation playbooks +js/exporter.js Markdown / PDF investigation export +js/compare.js before/after trace comparison +js/report.js report composer (offline) + optional Claude narration +js/app.js UI: import, case picker, ECG timeline, report, viewer +js/traces.js 5 sample failure cases +bench/forge.js seeded trace synthesizer (+ neutral-event inflation) +bench/run.js accuracy benchmark at dataset scale +bench/scale.js latency-vs-trace-size benchmark +docs/architecture.svg architecture diagram (embedded above) +docs/trace-schema.md canonical trace format documentation +tests/run-tests.js regression tests (node, no dependencies) +``` + +## Known Limitations & Future Work + +This system is **deterministic by design** — and that design has known, deliberate limits. Stating them precisely matters more than overstating what 13 rules can do. + +### What the current system cannot do + +* **Lexical, not semantic.** The groundedness and tool-affinity rules operate on token overlap and pattern shapes. Token overlap is not entailment, and a regex is not a semantic claim-checker: *"the parcel reached its destination"* and *"delivered"* are the same fact to a human and different strings to R22. Paraphrased hallucinations that reuse the source's vocabulary can slip through; correct answers phrased in fresh vocabulary can be flagged as unsupported. +* **Rule coverage is finite.** The engine detects the five failure classes it has rules for. Failure modes outside the catalog — looping/planning pathologies, context-window truncation, multi-agent coordination failures, prompt injection — currently produce either no diagnosis or a partial one. +* **Confidence is evidence weight, not calibrated probability.** `35 + 0.8 × points (cap 95)` is a transparent, monotone score — it is *not* a statistically calibrated probability. An 87 does not mean "correct 87% of the time"; it means "a lot of independent evidence fired." +* **The benchmark is synthetic.** The forged dataset (including its adversarial mutations) is generated by the same author as the rules. 94–95% accuracy on it demonstrates internal consistency and regression safety, not real-world recall. Numbers on production traces will be lower and messier — the benchmark's job is to keep the engine honest as it evolves, not to be a leaderboard claim. +* **Single-trace scope.** The engine diagnoses one run at a time. It does not yet aggregate across runs ("this tool times out on 4% of traffic, always between 14:00–15:00") — the fleet-level view is where the per-trace verdicts become operationally decisive. + +Consequently: **the report is a strong lead for an engineer, not a verdict.** Every rule is cheap, explainable and auditable — that is the point of the design — but an investigator's output should be read as evidence, which is exactly why confidence is capped at 95. + +### Planned next steps + +1. **Semantic layer behind the same rule interface.** Embedding-based groundedness (claim ↔ source cosine similarity) and semantic tool-affinity as *additional* rules registered through the same `definePlugin` contract. The architecture anticipates this: rules already return evidence + refs, so a semantic rule slots in without touching the engine. The deterministic rules stay as the cheap, auditable first pass; embeddings become a second opinion, not a replacement. +2. **LLM-as-rule, sandboxed like the narrator.** For claim verification that embeddings can't settle, an optional LLM-backed rule whose output is constrained to the same `{ evidence, refs }` shape — subject to the same invariant that stochastic components may add *candidate* evidence but the deterministic scorer still decides. +3. **Calibration.** Once real labeled traces exist, fit the confidence mapping to observed precision (isotonic regression is enough) so 80 means 80. +4. **Cross-run aggregation.** Persist verdicts and mine them: recurring rule patterns per flow, tool-latency trends, regression detection tied into the existing A/B compare. +5. **Per-flow custom rules.** The rule catalog is a plain array precisely so teams can ship domain rules ("a booking confirmation must contain a confirmation id") the way they ship prompts. diff --git a/kits/AGENT/agent-failure-investigator/bench/forge.js b/kits/AGENT/agent-failure-investigator/bench/forge.js new file mode 100644 index 000000000..0b5dca102 --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/bench/forge.js @@ -0,0 +1,169 @@ +let seed = 1337; +const rng = () => (seed = (seed * 48271) % 2147483647) / 2147483647; +const pick = arr => arr[Math.floor(rng() * arr.length)]; +const between = (lo, hi) => lo + Math.floor(rng() * (hi - lo + 1)); + +const CITIES = ["Berlin", "Beirut", "Tokyo", "Lagos", "Lima", "Oslo", "Doha", "Porto"]; +const ORDERS = () => `A-${between(10000, 99999)}`; +const CLOCK = () => `${String(between(9, 18)).padStart(2, "0")}:${String(between(0, 59)).padStart(2, "0")}:${String(between(0, 59)).padStart(2, "0")}`; + +const skeleton = () => { + const t = CLOCK(); + return { + meta: { agent: pick(["support-bot", "booking-bot", "hr-bot", "sales-bot"]), flow_id: `flow_${between(1000, 9999)}`, model: pick(["gpt-4o-mini", "claude-haiku", "llama-3-70b"]), session: `s${between(100, 999)}`, date: "2026-07-08" }, + system_prompt: "You are a support agent. Use the tools provided and base every answer on tool results and retrieved documents.", + conversation: [{ role: "user", ts: t, content: "" }], + available_tools: [ + { name: "get_order_status", description: "Look up live shipping status, carrier and ETA for an order id." }, + { name: "knowledge_base_search", description: "Search internal policy documents, SLA terms and refund rules." } + ], + tool_calls: [], retrieved_docs: [], logs: [], final_response: { ts: t, content: "" } + }; +}; + +const FORGES = { + TOOL_FAILURE() { + const t = skeleton(); + t.conversation[0].content = `Where is my order ${ORDERS()}? It's late.`; + const doomed = rng() < 0.5; + t.tool_calls.push({ ts: t.conversation[0].ts, tool: "get_order_status", input: { order_id: "x" }, status: doomed ? "timeout" : "error", duration_ms: doomed ? between(8000, 15000) : between(100, 900), output: "" }); + if (rng() < 0.7) t.logs.push({ ts: t.conversation[0].ts, level: "WARN", event: "flow.fallback", message: "fallback branch activated after tool failure" }); + t.final_response.content = `Your order will arrive by 2026-07-${between(10, 28)} via the standard carrier.`; + return t; + }, + HALLUCINATION() { + const t = skeleton(); + t.conversation[0].content = `Where is my order ${ORDERS()}?`; + t.tool_calls.push({ ts: t.conversation[0].ts, tool: "get_order_status", input: { order_id: "x" }, status: "success", duration_ms: between(90, 400), output: `{"status":"in_transit","carrier":"DHL","eta":"2026-07-12","last_scan":"${pick(CITIES)} hub"}` }); + t.final_response.content = `Great news — your package was delivered on July ${between(1, 6)}th and signed for at ${between(8, 11)}:00. A refund of $${between(20, 90)} was also issued per Section ${between(2, 7)}.${between(1, 9)}.`; + return t; + }, + RAG_FAILURE() { + const t = skeleton(); + t.conversation[0].content = "What is our refund SLA for enterprise customers?"; + const empty = rng() < 0.55; + t.tool_calls.push({ ts: t.conversation[0].ts, tool: "knowledge_base_search", input: { q: "refund sla" }, status: "success", duration_ms: between(60, 300), output: empty ? "[]" : "results below" }); + if (empty) { + t.logs.push({ ts: t.conversation[0].ts, level: "WARN", event: "retrieval.empty", message: "0 chunks above threshold" }); + } else { + const n = between(2, 4); + for (let i = 0; i < n; i++) t.retrieved_docs.push({ id: `doc-${i}`, source: "handbook.md", score: rng() * 0.35, content: "General onboarding notes about office equipment and travel booking procedures." }); + } + t.final_response.content = `Enterprise refunds are processed within ${between(3, 21)} business days, per Section ${between(1, 9)}.${between(1, 9)} of the policy.`; + return t; + }, + WRONG_TOOL() { + const t = skeleton(); + t.conversation[0].content = "What does our internal policy say about SLA credits and refund rules for enterprise accounts?"; + t.tool_calls.push({ ts: t.conversation[0].ts, tool: "get_order_status", input: { order_id: "none" }, status: "success", duration_ms: between(80, 500), output: `{"status":"unknown","carrier":null,"note":"no shipment found"}` }); + t.final_response.content = "Our policy offers generous credits for any missed commitments."; + return t; + }, + PROMPT_AMBIGUITY() { + const t = skeleton(); + t.system_prompt = "Always respond in a single sentence. Provide comprehensive, detailed answers with examples. Format output as appropriate, escalate if needed, add caveats when necessary, etc."; + t.conversation[0].content = "Summarize our shipping policy."; + t.tool_calls.push({ ts: t.conversation[0].ts, tool: "knowledge_base_search", input: { q: "shipping" }, status: "success", duration_ms: between(60, 250), output: "Standard shipping takes 5 to 9 business days per the policy document." }); + t.retrieved_docs.push({ id: "doc-1", source: "shipping-policy.md", score: 0.82, content: "Standard shipping takes 5 to 9 business days. Carrier delays are outside our control." }); + if (rng() < 0.6) t.logs.push({ ts: t.conversation[0].ts, level: "WARN", event: "output.validation", message: "response shape varies across recent runs" }); + t.final_response.content = "Standard shipping takes 5 to 9 business days; carrier delays are outside our control."; + return t; + }, + HEALTHY() { + const t = skeleton(); + const city = pick(CITIES); + t.conversation[0].content = `Where is my order ${ORDERS()}?`; + const payload = `{"status":"in_transit","carrier":"DHL","eta":"2026-07-12","last_scan":"${city} hub"}`; + t.tool_calls.push({ ts: t.conversation[0].ts, tool: "get_order_status", input: { order_id: "x" }, status: "success", duration_ms: between(90, 400), output: payload }); + t.retrieved_docs.push({ id: "doc-1", source: "shipping-policy.md", score: 0.84, content: "Standard shipping takes 5 to 9 business days. Track parcels with the carrier tracking id." }); + t.final_response.content = `Your order is in_transit with DHL, last scan at the ${city} hub, eta 2026-07-12. Standard shipping takes 5 to 9 business days.`; + t.logs.push({ ts: t.conversation[0].ts, level: "INFO", event: "tool.result", message: "200 OK" }); + return t; + } +}; + +const GRIME = { + HALLUCINATION(t) { + t.final_response.content = `Your order is in_transit with DHL. It was actually delivered on July ${between(1, 6)}th per Section ${between(2, 7)}.${between(1, 9)} of the carrier terms.`; + }, + RAG_FAILURE(t) { + if (t.retrieved_docs.length) t.retrieved_docs.forEach(d => { d.score = 0.42 + rng() * 0.16; }); + else t.logs = t.logs.filter(l => l.event !== "retrieval.empty"); + }, + PROMPT_AMBIGUITY(t) { + t.system_prompt = "Always respond in a single sentence. Provide comprehensive, detailed answers. Escalate if needed."; + t.logs = t.logs.filter(l => l.event !== "output.validation"); + }, + WRONG_TOOL(t) { + t.conversation[0].content = "Can you check the SLA credit situation on my recent order shipment?"; + }, + TOOL_FAILURE(t) { + t.logs = t.logs.filter(l => !/fallback/.test(l.event)); + }, + HEALTHY(t) { + t.tool_calls[0].duration_ms = between(4000, 7000); + t.logs.push({ ts: t.conversation[0].ts, level: "INFO", event: "flow.callback", message: "post-response webhook fired" }); + t.system_prompt += " Escalate to a human if needed."; + } +}; + +function forgeDataset(total) { + // Proportional mix (percent of total) so the class balance is identical + // at N=100, N=1,000 and N=10,000 and results stay comparable across scales. + const RATIOS = [ + ["TOOL_FAILURE", 0.18], ["HALLUCINATION", 0.18], ["RAG_FAILURE", 0.18], + ["WRONG_TOOL", 0.13], ["PROMPT_AMBIGUITY", 0.13] + ]; + const mix = RATIOS.map(([label, r]) => [label, Math.round(total * r)]); + mix.push(["HEALTHY", total - mix.reduce((s, [, n]) => s + n, 0)]); + const dataset = []; + mix.forEach(([label, n]) => { + for (let i = 0; i < n; i++) { + const trace = FORGES[label](); + if (rng() < 0.28) GRIME[label](trace); + dataset.push({ label: label === "HEALTHY" ? null : label, trace }); + } + }); + for (let i = dataset.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [dataset[i], dataset[j]] = [dataset[j], dataset[i]]; + } + return dataset; +} + +/* ---------- trace-size scaling (bench/scale.js) ---------- */ + +/** Count every event in a trace: messages + tool calls + docs + logs + final. */ +function countEvents(t) { + return (t.conversation || []).length + (t.tool_calls || []).length + + (t.retrieved_docs || []).length + (t.logs || []).length + + (t.final_response ? 1 : 0); +} + +/** + * Pad a forged trace up to `targetEvents` total events with *diagnostically + * neutral* traffic — the kind of noise a real long-running agent produces: + * INFO logs (~86%), intermediate assistant turns (~9%) and successful + * auxiliary heartbeat tool calls with bland outputs (~5%). None of these + * fire or unfire any rule, so the label stays valid: the benchmark asserts + * the verdict is unchanged at every size. + */ +const NOISE_EVENTS = ["node.enter", "node.exit", "llm.token_usage", "memory.checkpoint", "http.request", "http.response", "queue.dequeue", "cache.hit"]; +function inflateTrace(trace, targetEvents) { + const t = JSON.parse(JSON.stringify(trace)); + let need = targetEvents - countEvents(t); + while (need-- > 0) { + const r = rng(); + const ts = CLOCK(); + if (r < 0.86) { + t.logs.push({ ts, level: "INFO", event: pick(NOISE_EVENTS), message: `step ${between(1, 9999)} completed in ${between(1, 40)}ms` }); + } else if (r < 0.95) { + t.conversation.push({ role: "assistant", ts, content: `Working on it — step ${between(1, 999)} of the plan.` }); + } else { + t.tool_calls.push({ ts, tool: "heartbeat_ping", input: { seq: between(1, 9999) }, status: "success", duration_ms: between(1, 30), output: `{"ok":true}` }); + } + } + return t; +} + +module.exports = { forgeDataset, inflateTrace, countEvents }; diff --git a/kits/AGENT/agent-failure-investigator/bench/results.json b/kits/AGENT/agent-failure-investigator/bench/results.json new file mode 100644 index 000000000..8457a5afe --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/bench/results.json @@ -0,0 +1,32 @@ +{ + "generated": "2026-07-14T16:35:29.096Z", + "traces": 100, + "accuracy": "95.0%", + "false_positives": "0.0%", + "false_negatives": "0.0%", + "misclassified": "5.0%", + "avg_ms": 0.25, + "p95_ms": 0.76, + "per_category": { + "RAG_FAILURE": { + "total": 18, + "hit": 16 + }, + "TOOL_FAILURE": { + "total": 18, + "hit": 18 + }, + "PROMPT_AMBIGUITY": { + "total": 13, + "hit": 13 + }, + "HALLUCINATION": { + "total": 18, + "hit": 18 + }, + "WRONG_TOOL": { + "total": 13, + "hit": 10 + } + } +} \ No newline at end of file diff --git a/kits/AGENT/agent-failure-investigator/bench/run.js b/kits/AGENT/agent-failure-investigator/bench/run.js new file mode 100644 index 000000000..5447e27b6 --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/bench/run.js @@ -0,0 +1,69 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const { performance } = require("perf_hooks"); +const { forgeDataset } = require("./forge"); + +const ROOT = path.join(__dirname, ".."); +const sandbox = { console, module: { exports: {} } }; +vm.createContext(sandbox); +[ + "rules/core.js", "rules/tool_failure.js", "rules/hallucination.js", + "rules/rag.js", "rules/prompt.js", "rules/wrong_tool.js", "js/engine.js" +].forEach(f => vm.runInContext(fs.readFileSync(path.join(ROOT, f), "utf8"), sandbox, { filename: f })); + +const investigate = vm.runInContext("runInvestigation", sandbox); +const N = Number(process.argv[2]) || 100; +const dataset = forgeDataset(N); + +let correct = 0, falsePositive = 0, falseNegative = 0, misclassified = 0; +const perCategory = {}; +const timings = []; + +dataset.forEach(({ label, trace }) => { + const t0 = performance.now(); + const verdict = investigate(trace).primary; + timings.push(performance.now() - t0); + + if (label) { + perCategory[label] = perCategory[label] || { total: 0, hit: 0 }; + perCategory[label].total++; + if (verdict === label) { correct++; perCategory[label].hit++; } + else if (verdict === null) falseNegative++; + else misclassified++; + } else { + verdict === null ? correct++ : falsePositive++; + } +}); + +const pct = n => ((n / dataset.length) * 100).toFixed(1) + "%"; +const avgMs = (timings.reduce((a, b) => a + b, 0) / timings.length). + toFixed(2); +const sorted = [...timings].sort((a, b) => a - b); +const p95 = sorted[Math.floor(sorted.length * 0.95)].toFixed(2); + +const rows = [ + ["Traces", dataset.length], + ["Accuracy", pct(correct)], + ["False positives", pct(falsePositive)], + ["False negatives", pct(falseNegative)], + ["Misclassified", pct(misclassified)], + ["Avg time / trace", avgMs + " ms"], + ["p95 time / trace", p95 + " ms"] +]; + +console.log("\n BENCHMARK — deterministic rule engine\n " + "─".repeat(42)); +rows.forEach(([k, v]) => console.log(` ${String(k).padEnd(20)} ${v}`)); +console.log(" " + "─".repeat(42) + "\n Per-category recall:"); +Object.entries(perCategory).forEach(([k, s]) => + console.log(` ${k.padEnd(20)} ${s.hit}/${s.total} (${((s.hit / s.total) * 100).toFixed(0)}%)`)); +console.log(""); + +fs.writeFileSync(path.join(__dirname, "results.json"), JSON.stringify({ + generated: new Date().toISOString(), traces: dataset.length, + accuracy: pct(correct), false_positives: pct(falsePositive), + false_negatives: pct(falseNegative), misclassified: pct(misclassified), + avg_ms: Number(avgMs), p95_ms: Number(p95), + per_category: perCategory +}, null, 2)); +console.log(" results written to bench/results.json\n"); diff --git a/kits/AGENT/agent-failure-investigator/bench/scale-results.json b/kits/AGENT/agent-failure-investigator/bench/scale-results.json new file mode 100644 index 000000000..861e5d2ba --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/bench/scale-results.json @@ -0,0 +1,37 @@ +{ + "generated": "2026-07-14T16:38:56.052Z", + "node": "v22.22.2", + "traces_per_size": 60, + "results": [ + { + "events_per_trace": 100, + "traces": 60, + "avg_ms": 0.24, + "p50_ms": 0.18, + "p95_ms": 0.56, + "max_ms": 2.16, + "traces_per_second": 4229, + "verdicts_stable": "60/60" + }, + { + "events_per_trace": 1000, + "traces": 60, + "avg_ms": 1.2, + "p50_ms": 0.95, + "p95_ms": 3.09, + "max_ms": 5.55, + "traces_per_second": 833, + "verdicts_stable": "60/60" + }, + { + "events_per_trace": 10000, + "traces": 60, + "avg_ms": 12.19, + "p50_ms": 9.3, + "p95_ms": 29.97, + "max_ms": 63.17, + "traces_per_second": 82, + "verdicts_stable": "60/60" + } + ] +} \ No newline at end of file diff --git a/kits/AGENT/agent-failure-investigator/bench/scale.js b/kits/AGENT/agent-failure-investigator/bench/scale.js new file mode 100644 index 000000000..3d274afd3 --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/bench/scale.js @@ -0,0 +1,93 @@ +/** + * Trace-size scaling benchmark. + * + * node bench/scale.js # sizes 100, 1000, 10000 events + * node bench/scale.js 100 50000 # custom sizes + * + * For each size it forges 60 labeled traces (10 per class, healthy included), + * pads each one with diagnostically neutral events (INFO logs, assistant + * turns, heartbeat tool calls) up to the target size, then measures the full + * investigation — rules + conflict resolution + scoring + timeline — and + * asserts the verdict is IDENTICAL to the verdict on the un-padded trace. + * So the numbers below are latency *and* a robustness proof: accuracy does + * not degrade as traces grow. + * + * Results are written to bench/scale-results.json. + */ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const { performance } = require("perf_hooks"); +const { forgeDataset, inflateTrace, countEvents } = require("./forge"); + +const ROOT = path.join(__dirname, ".."); +const sandbox = { console, module: { exports: {} } }; +vm.createContext(sandbox); +[ + "rules/core.js", "rules/tool_failure.js", "rules/hallucination.js", + "rules/rag.js", "rules/prompt.js", "rules/wrong_tool.js", "js/engine.js" +].forEach(f => vm.runInContext(fs.readFileSync(path.join(ROOT, f), "utf8"), sandbox, { filename: f })); +const investigate = vm.runInContext("runInvestigation", sandbox); + +const SIZES = process.argv.slice(2).map(Number).filter(Boolean); +if (!SIZES.length) SIZES.push(100, 1000, 10000); +const TRACES_PER_SIZE = 60; +const stat = arr => { + const s = [...arr].sort((a, b) => a - b); + return { + avg: arr.reduce((a, b) => a + b, 0) / arr.length, + p50: s[Math.floor(s.length * 0.50)], + p95: s[Math.floor(s.length * 0.95)], + max: s[s.length - 1] + }; +}; + +// base corpus: verdicts on the small, un-padded traces are ground truth +const base = forgeDataset(TRACES_PER_SIZE); +base.forEach(item => { item.baseVerdict = investigate(item.trace).primary; }); + +console.log("\n SCALING BENCHMARK — engine latency vs trace size"); +console.log(" " + "─".repeat(74)); +console.log(" " + ["events/trace", "avg ms", "p50 ms", "p95 ms", "max ms", "traces/s", "verdicts"].map((h, i) => h.padEnd(i ? 10 : 14)).join("")); +console.log(" " + "─".repeat(74)); + +const results = []; +for (const size of SIZES) { + const timings = []; + let stable = 0; + for (const item of base) { + const big = inflateTrace(item.trace, size); + if (countEvents(big) !== size) throw new Error("inflation mismatch"); + // warm-up pass so JIT noise doesn't pollute the smallest size + investigate(big); + const t0 = performance.now(); + const verdict = investigate(big).primary; + timings.push(performance.now() - t0); + if (verdict === item.baseVerdict) stable++; + } + const s = stat(timings); + const throughput = Math.round(1000 / s.avg); + const stability = `${stable}/${base.length}`; + results.push({ + events_per_trace: size, traces: base.length, + avg_ms: +s.avg.toFixed(2), p50_ms: +s.p50.toFixed(2), + p95_ms: +s.p95.toFixed(2), max_ms: +s.max.toFixed(2), + traces_per_second: throughput, verdicts_stable: stability + }); + console.log(" " + [ + String(size).padEnd(14), s.avg.toFixed(2).padEnd(10), s.p50.toFixed(2).padEnd(10), + s.p95.toFixed(2).padEnd(10), s.max.toFixed(2).padEnd(10), + String(throughput).padEnd(10), stability + ].join("")); +} +console.log(" " + "─".repeat(74)); +console.log(" 'verdicts' = investigations whose category is unchanged after padding"); +console.log(" the trace with neutral events — accuracy must not degrade with size.\n"); + +fs.writeFileSync(path.join(__dirname, "scale-results.json"), JSON.stringify({ + generated: new Date().toISOString(), + node: process.version, + traces_per_size: TRACES_PER_SIZE, + results +}, null, 2)); +console.log(" results written to bench/scale-results.json\n"); diff --git a/kits/AGENT/agent-failure-investigator/css/style.css b/kits/AGENT/agent-failure-investigator/css/style.css new file mode 100644 index 000000000..732353cb0 --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/css/style.css @@ -0,0 +1,442 @@ +/* Agent Failure Investigator — diagnostic console theme. + Category colors are functional coding, not decoration: + each failure category keeps one hue everywhere in the UI. */ + +:root { + --bg: #0e1216; + --panel: #151b21; + --panel-2: #1a2229; + --line: #26313a; + --text: #e7ecea; + --dim: #8fa0a6; + + --red: #ff5d5d; + --amber: #ffb224; + --green: #43d69b; + --blue: #5ca8ff; + --violet: #b18cff; + --teal: #3ac6c0; + + --cat-hallucination: var(--violet); + --cat-tool: var(--red); + --cat-prompt: var(--amber); + --cat-wrongtool: var(--blue); + --cat-rag: var(--teal); + + --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --display: "Space Grotesk", system-ui, sans-serif; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + * { animation: none !important; transition: none !important; } +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--display); + font-size: 15px; + line-height: 1.55; + min-height: 100vh; +} + +.wrap { max-width: 1180px; margin: 0 auto; padding: 32px 24px 80px; } + +/* ---------- header ---------- */ + +header { margin-bottom: 36px; } + +.eyebrow { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.18em; + color: var(--dim); + text-transform: uppercase; +} + +h1 { + font-size: clamp(28px, 4.5vw, 44px); + font-weight: 700; + letter-spacing: -0.02em; + margin: 6px 0 8px; +} +h1 .accent { color: var(--red); } + +.tagline { color: var(--dim); max-width: 640px; } + +/* ---------- case picker ---------- */ + +.section-title { + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--dim); + margin: 28px 0 12px; + display: flex; + align-items: center; + gap: 10px; +} +.section-title::after { content: ""; flex: 1; height: 1px; background: var(--line); } + +#cases { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 10px; +} + +.case-card { + text-align: left; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px 14px 12px; + color: var(--text); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 5px; + font-family: var(--display); + transition: border-color .15s, transform .15s; +} +.case-card:hover { border-color: var(--dim); transform: translateY(-1px); } +.case-card:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } +.case-card.selected { border-color: var(--red); background: var(--panel-2); } + +.case-id { font-family: var(--mono); font-size: 10px; letter-spacing: .12em; color: var(--red); } +.case-label { font-size: 14px; font-weight: 600; line-height: 1.35; } +.case-hint { font-family: var(--mono); font-size: 11px; color: var(--dim); } + +.picker-actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; align-items: center; } + +button.primary { + background: var(--red); + color: #17090b; + border: none; + font-family: var(--display); + font-weight: 700; + font-size: 15px; + padding: 11px 22px; + border-radius: 8px; + cursor: pointer; + transition: filter .15s; +} +button.primary:hover:not(:disabled) { filter: brightness(1.1); } +button.primary:disabled { opacity: .35; cursor: not-allowed; } +button.primary:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; } + +button.ghost { + background: transparent; + color: var(--dim); + border: 1px solid var(--line); + font-family: var(--mono); + font-size: 12px; + padding: 10px 16px; + border-radius: 8px; + cursor: pointer; +} +button.ghost:hover { color: var(--text); border-color: var(--dim); } +button.ghost:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } + +#custom-area { margin-top: 12px; } +#custom-json { + width: 100%; + min-height: 160px; + background: var(--panel); + color: var(--text); + border: 1px solid var(--line); + border-radius: 10px; + font-family: var(--mono); + font-size: 12px; + padding: 12px; + resize: vertical; +} +#custom-json:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; } + +.hidden { display: none !important; } + +/* ---------- results ---------- */ + +#results { margin-top: 40px; } + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 20px; +} + +/* timeline */ +#timeline-scroll { overflow-x: auto; padding-bottom: 6px; } +.tl-ts { font-family: var(--mono); font-size: 10px; fill: var(--dim); } + +#timeline-labels { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 10px; +} +.tl-chip { + display: inline-flex; + gap: 8px; + align-items: baseline; + font-family: var(--mono); + font-size: 12px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 5px 9px; + background: var(--panel-2); +} +.tl-chip-ts { color: var(--dim); font-size: 10px; } +.tl-fail { border-color: var(--red); color: var(--red); } +.tl-warn { border-color: var(--amber); color: var(--amber); } +.tl-ok { color: var(--green); } +.tl-tool { color: var(--blue); } +.tl-arrow { color: var(--dim); font-size: 12px; } + +/* report grid */ +.report-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: 16px; + margin-top: 16px; +} +@media (max-width: 880px) { .report-grid { grid-template-columns: 1fr; } } + +.report-head { + display: flex; + align-items: center; + gap: 22px; + flex-wrap: wrap; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); + margin-bottom: 18px; +} + +.stamp { + --stamp-color: var(--red); + font-family: var(--mono); + font-weight: 700; + font-size: clamp(16px, 2.4vw, 22px); + letter-spacing: 0.12em; + color: var(--stamp-color); + border: 2px solid var(--stamp-color); + border-radius: 6px; + padding: 8px 16px; + transform: rotate(-2deg); + box-shadow: 0 0 0 1px rgba(0,0,0,.4) inset; +} + +.confidence-block { min-width: 200px; flex: 1; } +.confidence-line { display: flex; align-items: baseline; gap: 10px; } +#confidence { font-family: var(--mono); font-size: 26px; font-weight: 700; } +.confidence-label { font-family: var(--mono); font-size: 11px; letter-spacing: .12em; color: var(--dim); text-transform: uppercase; } +.confidence-track { height: 6px; background: var(--panel-2); border-radius: 3px; margin-top: 6px; overflow: hidden; } +#confidence-bar { display: block; height: 100%; width: 0; border-radius: 3px; transition: width .5s ease; } +#confidence-note { font-family: var(--mono); font-size: 10px; color: var(--dim); margin-top: 5px; } + +.field-title { + font-family: var(--mono); + font-size: 11px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--dim); + margin: 18px 0 8px; +} +.field-title:first-of-type { margin-top: 0; } + +.evidence-row { + display: flex; + gap: 10px; + align-items: baseline; + padding: 9px 0; + border-bottom: 1px dashed var(--line); + flex-wrap: wrap; +} +.evidence-row:last-child { border-bottom: none; } +.evidence-text { flex: 1; min-width: 220px; font-size: 14px; } +.evidence-refs { display: flex; gap: 6px; } + +.rule-chip { + font-family: var(--mono); + font-size: 11px; + border: 1px solid var(--dim); + color: var(--dim); + border-radius: 4px; + padding: 1px 6px; + white-space: nowrap; +} +.rule-chip.small { font-size: 10px; } + +.ref-link { + font-family: var(--mono); + font-size: 10px; + background: var(--panel-2); + color: var(--blue); + border: 1px solid var(--line); + border-radius: 4px; + padding: 2px 7px; + cursor: pointer; +} +.ref-link:hover { border-color: var(--blue); } + +#root-cause { font-size: 14.5px; } +.mode-tag { font-family: var(--mono); font-size: 10px; color: var(--dim); margin-top: 6px; } + +.contrib { display: flex; gap: 10px; align-items: baseline; padding: 6px 0; flex-wrap: wrap; } +.contrib-dot { width: 9px; height: 9px; border-radius: 50%; align-self: center; flex-shrink: 0; } +.contrib-name { font-weight: 600; font-size: 14px; } +.contrib-pts { font-family: var(--mono); font-size: 12px; color: var(--dim); } +.contrib-rules { font-size: 12.5px; color: var(--dim); flex-basis: 100%; padding-left: 19px; } + +ul.actions { list-style: none; } +ul.actions li { + padding: 8px 0 8px 0; + border-bottom: 1px dashed var(--line); + font-size: 14px; + display: flex; + gap: 9px; + align-items: baseline; +} +ul.actions li:last-child { border-bottom: none; } + +.muted { color: var(--dim); font-size: 13px; } + +/* rule engine panel */ +.score-bars { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; } +.score-row { display: grid; grid-template-columns: 130px 1fr 34px; gap: 10px; align-items: center; opacity: .65; } +.score-row.primary { opacity: 1; } +.score-name { font-size: 12.5px; } +.score-row.primary .score-name { font-weight: 700; } +.score-track { height: 7px; background: var(--panel-2); border-radius: 4px; overflow: hidden; } +.score-fill { display: block; height: 100%; border-radius: 4px; transition: width .5s ease; } +.score-pts { font-family: var(--mono); font-size: 12px; text-align: right; color: var(--dim); } + +.panel-subtitle { + font-family: var(--mono); font-size: 11px; letter-spacing: .12em; + text-transform: uppercase; color: var(--dim); margin: 4px 0 10px; +} + +.fired-rule { padding: 8px 0; border-bottom: 1px dashed var(--line); } +.fired-rule:last-child { border-bottom: none; } +.fired-head { display: flex; gap: 8px; align-items: baseline; } +.fired-title { flex: 1; font-size: 13px; } +.fired-pts { font-family: var(--mono); font-size: 12px; font-weight: 700; } + +/* LLM mode */ +.llm-row { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; } +#api-key { + flex: 1; + min-width: 220px; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--line); + border-radius: 8px; + font-family: var(--mono); + font-size: 12px; + padding: 9px 12px; +} +#api-key:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; } +.llm-note { font-family: var(--mono); font-size: 10.5px; color: var(--dim); margin-top: 6px; } + +/* trace viewer */ +#trace-viewer { display: flex; flex-direction: column; gap: 16px; } +.trace-section-title { + font-family: var(--mono); font-size: 11px; letter-spacing: .12em; + text-transform: uppercase; color: var(--dim); margin-bottom: 6px; +} +.trace-line { + font-family: var(--mono); + font-size: 12px; + padding: 5px 10px; + border-left: 2px solid var(--line); + white-space: pre-wrap; + word-break: break-word; + color: #c9d4d2; +} +.line-error { border-left-color: var(--red); color: var(--red); } +.line-warn { border-left-color: var(--amber); color: var(--amber); } + +.flash { animation: flash 1.6s ease; } +@keyframes flash { + 0% { background: rgba(255, 178, 36, .28); } + 100% { background: transparent; } +} + +/* toast */ +#toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translate(-50%, 12px); + background: var(--panel-2); + border: 1px solid var(--amber); + color: var(--text); + font-family: var(--mono); + font-size: 12.5px; + padding: 10px 18px; + border-radius: 8px; + opacity: 0; + pointer-events: none; + transition: opacity .25s, transform .25s; + max-width: 90vw; +} +#toast.show { opacity: 1; transform: translate(-50%, 0); } + +footer { + margin-top: 56px; + padding-top: 18px; + border-top: 1px solid var(--line); + font-family: var(--mono); + font-size: 11px; + color: var(--dim); + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +footer a { color: var(--dim); } + +.fw-strip { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 6px 0 22px; } +.fw-strip-label { font-size: 12px; color: var(--dim); letter-spacing: 0.06em; text-transform: uppercase; } +.fw-badge { font-family: "IBM Plex Mono", monospace; font-size: 12px; padding: 4px 10px; border: 1px solid var(--line); border-radius: 999px; color: var(--green, #3ecf8e); background: rgba(62, 207, 142, 0.06); } + +.upload-label { display: inline-flex; align-items: center; cursor: pointer; } +.upload-label.small { font-size: 12px; padding: 4px 10px; margin-left: 6px; } +.format-pill { font-family: "IBM Plex Mono", monospace; font-size: 12px; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--blue, #5aa9ff); color: var(--blue, #5aa9ff); } + +#graph-scroll { overflow-x: auto; padding: 8px 0; } +.gnode-label { font-family: "Space Grotesk", sans-serif; font-size: 15px; font-weight: 600; } +.gnode-tag { font-family: "IBM Plex Mono", monospace; font-size: 10px; letter-spacing: 0.12em; } +.gnode-bad rect { animation: pulse-bad 1.6s ease-in-out infinite; } +@keyframes pulse-bad { 50% { stroke-opacity: 0.45; } } + +.remedy { display: flex; gap: 12px; align-items: baseline; padding: 8px 0; border-bottom: 1px dashed var(--line); } +.remedy:last-child { border-bottom: none; } +.remedy-tag { font-family: "IBM Plex Mono", monospace; font-size: 12px; white-space: nowrap; padding: 3px 9px; border: 1px solid var(--green, #3ecf8e); color: var(--green, #3ecf8e); border-radius: 5px; } +.remedy-secondary .remedy-tag { border-color: var(--dim); color: var(--dim); } +.remedy-why { font-size: 13.5px; color: var(--fg); opacity: 0.85; } + +.export-row { display: flex; gap: 10px; margin-top: 18px; } + +.cmp-pickers { display: flex; align-items: flex-end; gap: 18px; flex-wrap: wrap; margin-bottom: 16px; } +.cmp-pickers select { background: transparent; color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 10px; font-family: "IBM Plex Mono", monospace; font-size: 12.5px; max-width: 320px; } +.cmp-vs { font-family: "Space Grotesk", sans-serif; font-weight: 700; color: var(--dim); padding-bottom: 8px; } +.cmp-table { width: 100%; border-collapse: collapse; margin: 6px 0 18px; font-size: 13.5px; } +.cmp-table th { text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--dim); padding: 6px 10px; border-bottom: 1px solid var(--line); } +.cmp-table td { padding: 8px 10px; border-bottom: 1px dashed var(--line); font-family: "IBM Plex Mono", monospace; } +.cmp-badge { font-size: 11px; padding: 2px 9px; border-radius: 999px; border: 1px solid; } +.cmp-better { color: var(--green, #3ecf8e); border-color: var(--green, #3ecf8e); } +.cmp-worse { color: var(--red, #ff4d4d); border-color: var(--red, #ff4d4d); } +.cmp-same { color: var(--dim); border-color: var(--line); } +.cmp-changed { color: var(--amber, #f2b544); border-color: var(--amber, #f2b544); } +.cmp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; } +.cmp-rule { font-size: 13px; padding: 6px 0; border-bottom: 1px dashed var(--line); } +.cmp-solved { color: var(--green, #3ecf8e); } +.cmp-new { color: var(--red, #ff4d4d); } +.cmp-open { color: var(--amber, #f2b544); } diff --git a/kits/AGENT/agent-failure-investigator/docs/adapters.md b/kits/AGENT/agent-failure-investigator/docs/adapters.md new file mode 100644 index 000000000..41b16638c --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/docs/adapters.md @@ -0,0 +1,38 @@ +# Import adapters + +`js/adapters.js` holds one adapter per framework. An adapter is two functions: + +| Function | Contract | +|---|---| +| `claim(doc)` | Cheap structural sniff — returns true if the JSON looks like this framework's export. Adapters are tried in order; the first claim wins. | +| `translate(doc)` | Best-effort mapping onto the canonical trace schema (`docs/trace-schema.md`). Missing sections stay empty; the rule engine tolerates that. | + +## Detection signatures + +| Format | Claimed when | +|---|---| +| Native | Object with `final_response` or `tool_calls` (and no `spans`) | +| LangGraph | Array (or `{events}`) of `astream_events` items: `event: "on_*"` or `metadata.langgraph_node` | +| OpenAI Agents SDK | `spans[]` where items carry `span_data.type` (`agent`, `function`, `generation`, `guardrail`, `handoff`) | +| CrewAI | `tasks[]` plus a `crew` or agents with `role`/`goal` | +| AutoGen | `chat_history[]` (or bare message array) with `name` / `tool_calls` / `function_call` fields | +| Lamatic | `nodes[]` plus `flowId` / `executionId` / `workflowId` | + +## Field mapping highlights + +- **Tool calls**: LangGraph `on_tool_start/end/error` pairs, OpenAI `function` spans, CrewAI `tool_calls`/`tools_used`, AutoGen `tool_calls` → `role:"tool"` replies, Lamatic `toolNode`/`apiNode`/`codeNode` executions. Error text containing "timeout" maps to `status:"timeout"`, anything else failing maps to `status:"error"`. +- **Retrieval**: LangGraph `on_retriever_end` documents and Lamatic `ragNode`/`vector` node outputs become `retrieved_docs` with their scores. +- **System prompt**: `system` messages (LangGraph, OpenAI, AutoGen), agent `role/goal/backstory` (CrewAI), `config.systemPrompt` (Lamatic). +- **Final response**: the last assistant generation / task output / `llmNode` text. +- **Logs**: guardrails, handoffs, node lifecycle, span errors, and fallback markers land in `logs` so the timeline and fallback rules see them. +- **Timestamps**: ISO strings and epoch numbers are folded to `HH:MM:SS`; ordering is what matters to the engine. + +## Adding a framework + +```js +new Adapter("myframework", "My Framework", + doc => Array.isArray(doc.runs) && doc.runs.some(r => r.step_type), + doc => { /* return an object in the canonical schema */ }); +``` + +Register it before the stricter formats if its signature could shadow theirs, add a fixture to `tests/run-tests.js`, done. diff --git a/kits/AGENT/agent-failure-investigator/docs/architecture.svg b/kits/AGENT/agent-failure-investigator/docs/architecture.svg new file mode 100644 index 000000000..9b83678d4 --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/docs/architecture.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + AGENT FAILURE INVESTIGATOR — ARCHITECTURE + Data flow: raw framework trace → canonical schema → deterministic diagnosis → investigation report + + + + + + STAGE 1 + Input & Adapters + js/adapters.js + + + trace.json (any framework) + + + + LangGraph + OpenAI SDK + CrewAI + AutoGen + Lamatic + Native + + + each adapter: + claim(doc) auto-detect + translate(doc) convert + The engine never learns which + framework the trace came from. + + + + + + + STAGE 2 + Canonical Trace + docs/trace-schema.md + + + + { + system_prompt + conversation[] + available_tools[] + tool_calls[] + retrieved_docs[] + logs[] + final_response + } + + One schema. The full request + lifecycle of a single agent run. + + + + + + + STAGE 3 + Rule Engine + rules/ — pluggable, 13 pure rules + + + tool_failure.js R11 R12 R13 + hallucination.js R14 R22 + rag.js R21 R23 R25 + prompt.js R31 R32 R33 + wrong_tool.js R41 R42 + + + + test(trace) → + { evidence, refs[] } | null + + Every finding carries references + into the raw trace. No black box. + + + + + + + STAGE 4 + Resolution & Scoring + js/engine.js + + + 1  conflict resolution + root causes suppress symptoms + 2  evidence-weighted points + per failure category + 3  primary category = argmax + 4  timeline reconstruction + + + + conf = 35 + 0.8·pts (cap 95) + + Never 100% — an investigator + reports evidence, not certainty. + + + + + + + STAGE 5 + Report & Outputs + report.js · advisor.js · exporter.js + + + ▸ failure report + evidence + ▸ propagation graph + ▸ ECG timeline + ▸ remediation playbook + ▸ A/B run comparison + + + + Export .md + Export PDF + + + Every claim in the report is + clickable back to raw evidence + [R11] [R13] … in the trace. + + + + OPTIONAL — OFF BY DEFAULT + LLM Narration (Claude API, js/report.js) + Rewrites the Root Cause section as prose from the engine's fired rules — + receives findings as fixed facts; cannot add, remove or reweigh evidence. + + language only, + never diagnosis + + + + DESIGN INVARIANT + Diagnosis is deterministic. Language is not. + Same trace in → same verdict out, every run, offline, in <1 ms typical. Rules are cheap, + explainable and auditable; the report is reproducible evidence, not model opinion. + + + Extending: drop a file into rules/ calling definePlugin({ id, category, points, title, fix, prevention, test }) — no engine change, no build step. + diff --git a/kits/AGENT/agent-failure-investigator/docs/trace-schema.md b/kits/AGENT/agent-failure-investigator/docs/trace-schema.md new file mode 100644 index 000000000..00e29a62c --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/docs/trace-schema.md @@ -0,0 +1,39 @@ +# Trace schema + +A trace is one agent run, captured as a single JSON object. Every field the rule engine reads is documented here; unknown fields are ignored, and every section except `final_response` may be empty. + +## Top-level fields + +| Field | Type | Purpose | +|---|---|---| +| `meta` | object | Display only: `agent`, `flow_id`, `model`, `session`, `date`. | +| `system_prompt` | string | The agent's instructions. Read by the prompt-ambiguity rules (R31, R32). | +| `conversation` | array | Messages: `{ role: "user"\|"assistant", ts, content }`. The last user message defines the task for tool-selection rules (R41, R42). | +| `available_tools` | array | `{ name, description }`. The full toolbox the agent could have used — required for wrong-tool detection. | +| `tool_calls` | array | See below. | +| `retrieved_docs` | array | `{ id, source, score, content }`. Retrieval results that reached the context. Read by RAG rules (R21, R23) and the groundedness rule (R22). | +| `logs` | array | `{ ts, level: "INFO"\|"WARN"\|"ERROR", event, message }`. Infrastructure events. Fallbacks, empty-retrieval warnings and output-instability warnings are recognized by event name (`flow.fallback`, `retrieval.empty`, `output.validation`). | +| `final_response` | object | `{ ts, content }`. The answer the user actually received — the "body" of the investigation. | + +## Tool call fields + +```json +{ + "ts": "10:33:02", + "tool": "get_order_status", + "input": { "order_id": "A-58291" }, + "status": "success", + "duration_ms": 214, + "output": "{\"status\":\"in_transit\",\"eta\":\"2026-07-12\"}" +} +``` + +`status` must be one of `success`, `error`, `timeout`. `output` is a string (stringify structured results). Timeouts and errors feed the tool-failure rules (R11, R12); successful outputs feed the groundedness corpus for R14 and R22. + +## Timestamps + +`ts` is a `HH:MM:SS` string. Timestamps only need to sort correctly relative to each other — the timeline is reconstructed by ordering, not by wall-clock math (except timeout endpoints, computed from `duration_ms`). + +## Design note + +The schema mirrors the request lifecycle that agent platforms already log: prompt in, tools and retrieval in the middle, response out. Mapping a platform's native run log onto this shape is an adapter, not a redesign — which is the point. diff --git a/kits/AGENT/agent-failure-investigator/index.html b/kits/AGENT/agent-failure-investigator/index.html new file mode 100644 index 000000000..7210626f6 --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/index.html @@ -0,0 +1,164 @@ + + + + + + Agent Failure Investigator + + + + + +
+ +
+
Diagnostic tooling for agent developers
+

Agent Failure Investigator

+

+ Every AI team asks the same question: why did the agent fail? + Load a trace — conversation, logs, tool calls, retrieved docs, final response — + and get a deterministic failure report with evidence you can click. +

+
+ +
+ Import traces from + ✓ LangGraph + ✓ OpenAI Agents + ✓ CrewAI + ✓ AutoGen + ✓ Lamatic + ✓ Native +
+ +
Open a case
+
+
+ + + + + +
+ + + + +
Compare two runs (before / after a fix)
+
+
+
+
Trace A — before
+ + + +
+
vs
+
+
Trace B — after
+ + + +
+ +
+
+
+ +
+ Agent Failure Investigator — deterministic rule engine + optional LLM narration + 13 pluggable rules · 5 failure categories · 6 trace formats · zero build step +
+
+ +
+ + + + + + + + + + + + + + + + + + diff --git a/kits/AGENT/agent-failure-investigator/js/adapters.js b/kits/AGENT/agent-failure-investigator/js/adapters.js new file mode 100644 index 000000000..f870093db --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/js/adapters.js @@ -0,0 +1,268 @@ +const clockOf = raw => { + if (raw == null) return "00:00:00"; + if (typeof raw === "number") { + const d = new Date(raw < 1e12 ? raw * 1000 : raw); + return isNaN(d) ? "00:00:00" : d.toISOString().slice(11, 19); + } + const s = String(raw); + if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s; + const d = new Date(s); + return isNaN(d) ? "00:00:00" : d.toISOString().slice(11, 19); +}; + +const asText = v => v == null ? "" : (typeof v === "string" ? v : JSON.stringify(v)); + +const emptyTrace = meta => ({ + meta, system_prompt: "", conversation: [], available_tools: [], + tool_calls: [], retrieved_docs: [], logs: [], final_response: null +}); + +const ADAPTERS = []; + +class Adapter { + constructor(id, label, claim, translate) { + this.id = id; this.label = label; this.claim = claim; this.translate = translate; + ADAPTERS.push(this); + } +} + +new Adapter("native", "Native", doc => + doc && typeof doc === "object" && !Array.isArray(doc) && + ("final_response" in doc || "tool_calls" in doc) && !("spans" in doc), + doc => doc +); + +new Adapter("langgraph", "LangGraph", doc => { + const arr = Array.isArray(doc) ? doc : doc?.events; + return Array.isArray(arr) && arr.some(e => + typeof e?.event === "string" && e.event.startsWith("on_") || + e?.metadata?.langgraph_node !== undefined); +}, doc => { + const events = Array.isArray(doc) ? doc : doc.events; + const out = emptyTrace({ agent: "langgraph-run", model: "", flow_id: "", session: "", date: "" }); + const openTools = {}; + events.forEach(ev => { + const ts = clockOf(ev.timestamp || ev.ts || ev.time); + const kind = ev.event || ""; + const data = ev.data || {}; + if (kind === "on_chain_start" && data.input?.messages) { + data.input.messages.forEach(m => { + const role = m.type === "human" || m.role === "user" ? "user" : m.type === "system" ? "system" : "assistant"; + if (role === "system") out.system_prompt = out.system_prompt || asText(m.content); + else out.conversation.push({ role, ts, content: asText(m.content) }); + }); + } + if (kind === "on_tool_start") { + openTools[ev.run_id || ev.name] = { ts, tool: ev.name || "tool", input: data.input ?? {}, status: "success", duration_ms: 0, output: "" }; + } + if (kind === "on_tool_end") { + const rec = openTools[ev.run_id || ev.name] || { ts, tool: ev.name || "tool", input: {}, duration_ms: 0 }; + rec.output = asText(data.output); + rec.status = /error|exception|traceback/i.test(rec.output) ? "error" : "success"; + rec.duration_ms = ev.duration_ms || rec.duration_ms || 0; + out.tool_calls.push(rec); + delete openTools[ev.run_id || ev.name]; + } + if (kind === "on_tool_error") { + const rec = openTools[ev.run_id || ev.name] || { ts, tool: ev.name || "tool", input: {}, duration_ms: ev.duration_ms || 0 }; + rec.status = /timeout/i.test(asText(data.error)) ? "timeout" : "error"; + rec.output = asText(data.error); + out.tool_calls.push(rec); + delete openTools[ev.run_id || ev.name]; + } + if (kind === "on_retriever_end") { + (data.output?.documents || data.documents || []).forEach((d, i) => out.retrieved_docs.push({ + id: d.id || `lg-doc-${i}`, source: d.metadata?.source || "retriever", + score: d.metadata?.score ?? d.score ?? 0, content: asText(d.page_content ?? d.content) + })); + } + if (kind === "on_chat_model_end" || kind === "on_llm_end") { + const gen = data.output?.generations?.[0]?.[0]?.text ?? data.output?.content ?? data.output; + out.final_response = { ts, content: asText(gen) }; + } + if (ev.metadata?.langgraph_node) { + out.logs.push({ ts, level: "INFO", event: `node.${ev.metadata.langgraph_node}`, message: kind }); + } + if (/error|fallback/i.test(kind)) { + out.logs.push({ ts, level: "WARN", event: kind, message: asText(data.error || data) }); + } + }); + return out; +}); + +new Adapter("openai-agents", "OpenAI Agents SDK", doc => + doc && Array.isArray(doc.spans) && doc.spans.some(s => s?.span_data?.type), + doc => { + const out = emptyTrace({ + agent: doc.workflow_name || "openai-agents-run", model: "", + flow_id: doc.id || doc.trace_id || "", session: doc.group_id || "", date: "" + }); + doc.spans.forEach(span => { + const d = span.span_data || {}; + const ts = clockOf(span.started_at); + const ms = span.started_at && span.ended_at + ? Math.max(0, new Date(span.ended_at) - new Date(span.started_at)) : 0; + switch (d.type) { + case "agent": + out.meta.agent = d.name || out.meta.agent; + (d.tools || []).forEach(t => out.available_tools.push( + typeof t === "string" ? { name: t, description: "" } : { name: t.name, description: t.description || "" })); + break; + case "function": { + const errText = asText(span.error?.message || d.error); + out.tool_calls.push({ + ts, tool: d.name || "function", input: safeJson(d.input), + status: span.error ? (/timeout/i.test(errText) ? "timeout" : "error") : "success", + duration_ms: ms, output: asText(d.output) + }); + break; + } + case "generation": + case "response": { + out.meta.model = d.model || out.meta.model; + (d.input || []).forEach(m => { + if (m.role === "system") out.system_prompt = out.system_prompt || asText(m.content); + else if (m.role === "user") out.conversation.push({ role: "user", ts, content: asText(m.content) }); + }); + const last = (d.output || []).filter(m => m.role === "assistant").pop(); + if (last) out.final_response = { ts: clockOf(span.ended_at || span.started_at), content: asText(last.content) }; + break; + } + case "guardrail": + out.logs.push({ ts, level: d.triggered ? "WARN" : "INFO", event: "guardrail", message: d.name || "" }); + break; + case "handoff": + out.logs.push({ ts, level: "INFO", event: "agent.handoff", message: `${d.from_agent || "?"} -> ${d.to_agent || "?"}` }); + break; + } + if (span.error) out.logs.push({ ts, level: "ERROR", event: "span.error", message: asText(span.error.message || span.error) }); + }); + return out; + } +); + +new Adapter("crewai", "CrewAI", doc => + doc && Array.isArray(doc.tasks) && (doc.crew !== undefined || (doc.agents || []).some(a => a?.role)), + doc => { + const out = emptyTrace({ + agent: asText(doc.crew?.name || doc.crew || "crew"), model: doc.crew?.llm || "", + flow_id: doc.id || "", session: "", date: "" + }); + (doc.agents || []).forEach(a => { + const brief = [a.role, a.goal, a.backstory].filter(Boolean).join(". "); + out.system_prompt = out.system_prompt ? out.system_prompt + "\n" + brief : brief; + (a.tools || []).forEach(t => out.available_tools.push( + typeof t === "string" ? { name: t, description: "" } : { name: t.name, description: t.description || "" })); + }); + doc.tasks.forEach((task, i) => { + const ts = clockOf(task.start_time || task.created_at || i); + out.conversation.push({ role: "user", ts, content: asText(task.description) }); + (task.tool_calls || task.tools_used || []).forEach(u => out.tool_calls.push({ + ts: clockOf(u.timestamp || u.start_time || ts), + tool: u.tool || u.name || "tool", input: u.input ?? u.arguments ?? {}, + status: u.error ? (/timeout/i.test(asText(u.error)) ? "timeout" : "error") : (u.status || "success"), + duration_ms: u.duration_ms || 0, output: asText(u.result ?? u.output ?? u.error) + })); + const done = task.output?.raw ?? task.output ?? task.result; + if (done != null) out.final_response = { ts: clockOf(task.end_time || ts), content: asText(done) }; + out.logs.push({ ts, level: "INFO", event: "task.assigned", message: `${task.agent || "agent"} <- ${asText(task.name || task.description).slice(0, 60)}` }); + }); + return out; + } +); + +new Adapter("autogen", "AutoGen", doc => { + const hist = doc?.chat_history || (Array.isArray(doc) ? doc : null); + return Array.isArray(hist) && hist.some(m => m && "role" in m && ("name" in m || "function_call" in m || "tool_calls" in m)); +}, doc => { + const hist = doc.chat_history || doc; + const out = emptyTrace({ agent: "autogen-chat", model: "", flow_id: "", session: "", date: "" }); + const pending = {}; + hist.forEach((m, i) => { + const ts = clockOf(m.timestamp || i); + if (m.role === "system") { out.system_prompt = out.system_prompt || asText(m.content); return; } + (m.tool_calls || (m.function_call ? [{ id: `fc-${i}`, function: m.function_call }] : [])).forEach(call => { + pending[call.id || call.function?.name] = { + ts, tool: call.function?.name || "function", + input: safeJson(call.function?.arguments), status: "success", duration_ms: 0, output: "" + }; + }); + if (m.role === "tool" || m.role === "function") { + const key = m.tool_call_id || m.name; + const rec = pending[key] || { ts, tool: m.name || "function", input: {}, duration_ms: 0 }; + rec.output = asText(m.content); + rec.status = /error|exception|timed?\s*out/i.test(rec.output) ? (/timed?\s*out/i.test(rec.output) ? "timeout" : "error") : "success"; + out.tool_calls.push(rec); + delete pending[key]; + return; + } + if (m.role === "user") out.conversation.push({ role: "user", ts, content: asText(m.content) }); + if (m.role === "assistant" && m.content) out.final_response = { ts, content: asText(m.content) }; + }); + if (doc.summary && !out.final_response) out.final_response = { ts: "00:00:00", content: asText(doc.summary) }; + return out; +}); + +new Adapter("lamatic", "Lamatic", doc => + doc && Array.isArray(doc.nodes) && (doc.flowId !== undefined || doc.executionId !== undefined || doc.workflowId !== undefined), + doc => { + const out = emptyTrace({ + agent: doc.flowName || "lamatic-flow", model: "", + flow_id: doc.flowId || doc.workflowId || "", session: doc.executionId || "", date: "" + }); + doc.nodes.forEach((node, i) => { + const ts = clockOf(node.startedAt || node.timestamp || i); + const kind = String(node.nodeType || node.type || "").toLowerCase(); + const ok = !node.error && node.status !== "failed" && node.status !== "error"; + if (kind.includes("trigger") || kind.includes("input")) { + out.conversation.push({ role: "user", ts, content: asText(node.output?.query ?? node.input?.query ?? node.output ?? node.input) }); + } else if (kind.includes("rag") || kind.includes("retriev") || kind.includes("vector") || kind.includes("memory")) { + out.tool_calls.push({ + ts, tool: node.nodeName || "vector_search", input: node.input ?? {}, + status: ok ? "success" : "error", duration_ms: node.durationMs || node.duration_ms || 0, + output: asText(node.output) + }); + (node.output?.documents || node.output?.chunks || []).forEach((d, j) => out.retrieved_docs.push({ + id: d.id || `lm-doc-${j}`, source: d.source || d.metadata?.source || "vector-db", + score: d.score ?? d.similarity ?? 0, content: asText(d.content ?? d.text) + })); + } else if (kind.includes("llm") || kind.includes("generate") || kind.includes("agent")) { + out.meta.model = node.model || node.config?.model || out.meta.model; + if (node.config?.systemPrompt) out.system_prompt = out.system_prompt || node.config.systemPrompt; + const text = node.output?.text ?? node.output?.response ?? node.output; + if (ok && text != null) out.final_response = { ts: clockOf(node.endedAt || ts), content: asText(text) }; + } else if (kind.includes("tool") || kind.includes("api") || kind.includes("code") || kind.includes("http")) { + const errText = asText(node.error); + out.tool_calls.push({ + ts, tool: node.nodeName || node.toolName || "tool", input: node.input ?? {}, + status: ok ? "success" : (/timeout/i.test(errText + node.status) ? "timeout" : "error"), + duration_ms: node.durationMs || node.duration_ms || 0, + output: asText(node.output ?? node.error) + }); + } + out.logs.push({ + ts, level: ok ? "INFO" : "ERROR", + event: `node.${node.nodeName || kind || i}`, + message: ok ? (node.status || "completed") : asText(node.error || node.status) + }); + if (node.fallback || /fallback/i.test(asText(node.status))) { + out.logs.push({ ts, level: "WARN", event: "flow.fallback", message: "fallback branch taken" }); + } + }); + return out; + } +); + +function safeJson(v) { + if (v == null) return {}; + if (typeof v !== "string") return v; + try { return JSON.parse(v); } catch { return { raw: v }; } +} + +function importTrace(doc) { + const match = ADAPTERS.find(a => { try { return a.claim(doc); } catch { return false; } }); + if (!match) throw new Error("Unrecognized trace format — expected native, LangGraph, OpenAI Agents SDK, CrewAI, AutoGen, or Lamatic."); + return { format: match.id, formatLabel: match.label, trace: match.translate(doc) }; +} + +if (typeof module !== "undefined") module.exports = { importTrace, ADAPTERS }; diff --git a/kits/AGENT/agent-failure-investigator/js/advisor.js b/kits/AGENT/agent-failure-investigator/js/advisor.js new file mode 100644 index 000000000..03000571c --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/js/advisor.js @@ -0,0 +1,50 @@ +const PLAYBOOKS = { + TOOL_FAILURE: [ + { tag: "Retry policy", why: "Transient tool failures disappear with 2–3 retries and exponential backoff plus jitter." }, + { tag: "Circuit breaker", why: "Trip after N consecutive failures so the flow fails fast instead of stacking timeouts." }, + { tag: "Increase timeout", why: "If p95 latency of the dependency sits near the limit, the limit is the bug." }, + { tag: "Cache results", why: "Serve the last known-good result for idempotent lookups while the dependency recovers." }, + { tag: "Async execution", why: "Long-running tools should be fire-and-poll, not block the generation path." }, + { tag: "Honest fallback", why: "The fallback branch must say the operation failed — never synthesize a confirmation." } + ], + HALLUCINATION: [ + { tag: "Groundedness gate", why: "Extract dates, amounts and IDs from the draft and verify each against sources before sending." }, + { tag: "Strict context injection", why: "Pipe tool output verbatim into the final prompt with an answer-only-from-this instruction." }, + { tag: "Citation requirement", why: "Force the model to attach a source ref to every factual sentence; drop sentences without one." }, + { tag: "Temperature review", why: "Factual flows rarely justify temperature above 0.3." }, + { tag: "Refusal path", why: "Give the model an explicit, rewarded way to say it doesn't know." } + ], + RAG_FAILURE: [ + { tag: "Query reformulation", why: "On empty retrieval, auto-broaden terms and retry before generation is allowed." }, + { tag: "Min-score guardrail", why: "Block factual answers unless one document clears the relevance threshold." }, + { tag: "Chunking review", why: "Sub-0.5 top scores usually mean chunks are too large or split mid-concept." }, + { tag: "Hybrid search", why: "Combine dense vectors with BM25 keywords to survive vocabulary mismatch." }, + { tag: "Index freshness alert", why: "Alert when the corpus update lag exceeds the content change rate." } + ], + WRONG_TOOL: [ + { tag: "Mutually exclusive tool specs", why: "Overlapping descriptions make routing a coin flip; rewrite them to partition the query space." }, + { tag: "Routing examples", why: "Few-shot routing pairs (query → correct tool) fix most selection errors cheaply." }, + { tag: "Re-plan step", why: "After an irrelevant result, loop back to planning instead of forcing generation." }, + { tag: "Tool-choice eval suite", why: "Run selection accuracy tests on every prompt or tool-description change." } + ], + PROMPT_AMBIGUITY: [ + { tag: "Prompt linter", why: "Catch contradictory instruction pairs (concise vs. comprehensive) in CI, before deploy." }, + { tag: "Explicit output contract", why: "Pin exact length, structure and tone; delete every 'as appropriate'." }, + { tag: "Output-shape validator", why: "Validate the response shape at runtime so drift pages the team, not the users." }, + { tag: "Prompt versioning", why: "Version prompts like code — diffs make regressions findable." } + ] +}; + +function adviseRemediation(result) { + if (!result.primary) return []; + const deck = [...(PLAYBOOKS[result.primary] || [])]; + Object.entries(result.scores) + .filter(([k, pts]) => k !== result.primary && pts > 0) + .sort((a, b) => b[1] - a[1]) + .forEach(([k]) => (PLAYBOOKS[k] || []).slice(0, 2).forEach(item => { + if (!deck.some(d => d.tag === item.tag)) deck.push({ ...item, secondary: true }); + })); + return deck; +} + +if (typeof module !== "undefined") module.exports = { adviseRemediation, PLAYBOOKS }; diff --git a/kits/AGENT/agent-failure-investigator/js/app.js b/kits/AGENT/agent-failure-investigator/js/app.js new file mode 100644 index 000000000..9480ade9e --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/js/app.js @@ -0,0 +1,436 @@ +/** + * UI layer. No framework — a diagnostic tool should open from file:// + * with zero build step. All state lives in memory. + */ + +let currentTraceId = null; +let currentTrace = null; +let lastResult = null; + +let importedFormatLabel = null; + +document.addEventListener("DOMContentLoaded", () => { + renderCasePicker(); + document.getElementById("run-btn").addEventListener("click", onRun); + document.getElementById("custom-toggle").addEventListener("click", toggleCustom); + document.getElementById("llm-btn").addEventListener("click", onComposeWithClaude); + document.getElementById("trace-file").addEventListener("change", onUploadTrace); + document.getElementById("export-md").addEventListener("click", () => lastResult && saveMarkdownReport(lastResult, composeReport(lastResult, currentTrace), currentTrace, importedFormatLabel)); + document.getElementById("export-pdf").addEventListener("click", () => lastResult && printPdfReport(lastResult, composeReport(lastResult, currentTrace), currentTrace, importedFormatLabel)); + bootCompare(); +}); + +function onUploadTrace(ev) { + const file = ev.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + let doc; + try { doc = JSON.parse(reader.result); } + catch { showToast("That file is not valid JSON."); return; } + let imported; + try { imported = importTrace(doc); } + catch (e) { showToast(e.message); return; } + currentTrace = imported.trace; + currentTraceId = "upload"; + importedFormatLabel = imported.formatLabel; + document.querySelectorAll(".case-card").forEach(c => c.classList.remove("selected")); + document.getElementById("custom-area").classList.add("hidden"); + const pill = document.getElementById("format-pill"); + pill.textContent = "Detected: " + imported.formatLabel; + pill.classList.remove("hidden"); + document.getElementById("run-btn").disabled = false; + showToast(`Imported ${file.name} as a ${imported.formatLabel} trace.`); + }; + reader.readAsText(file); + ev.target.value = ""; +} + +function bootCompare() { + const cmpTraces = { a: null, b: null }; + const fill = sel => { + sel.innerHTML = '' + + SAMPLE_TRACES.map(c => ``).join(""); + }; + const selA = document.getElementById("cmp-a"), selB = document.getElementById("cmp-b"); + fill(selA); fill(selB); + const wireUpload = (inputId, slot) => document.getElementById(inputId).addEventListener("change", ev => { + const f = ev.target.files[0]; + if (!f) return; + const r = new FileReader(); + r.onload = () => { + try { cmpTraces[slot] = importTrace(JSON.parse(r.result)).trace; showToast(`Trace ${slot.toUpperCase()} loaded from file.`); } + catch (e) { showToast(e.message || "Bad file."); } + }; + r.readAsText(f); + ev.target.value = ""; + }); + wireUpload("cmp-a-file", "a"); + wireUpload("cmp-b-file", "b"); + selA.addEventListener("change", () => { cmpTraces.a = pickSample(selA.value); }); + selB.addEventListener("change", () => { cmpTraces.b = pickSample(selB.value); }); + document.getElementById("cmp-run").addEventListener("click", () => { + if (!cmpTraces.a || !cmpTraces.b) { showToast("Pick or upload both traces first."); return; } + renderComparison("cmp-result", diffInvestigations(cmpTraces.a, cmpTraces.b)); + }); +} + +const pickSample = id => (SAMPLE_TRACES.find(c => c.id === id) || {}).trace || null; + +/* ---------- case picker ---------- */ + +function renderCasePicker() { + const wrap = document.getElementById("cases"); + wrap.innerHTML = ""; + for (const c of SAMPLE_TRACES) { + const el = document.createElement("button"); + el.className = "case-card"; + el.dataset.id = c.id; + el.innerHTML = `${c.id.toUpperCase()} + ${escapeHtml(c.label.replace(/^Case #\d+ — /, ""))} + ${escapeHtml(c.hint)}`; + el.addEventListener("click", () => selectCase(c.id, el)); + wrap.appendChild(el); + } +} + +function selectCase(id, el) { + document.querySelectorAll(".case-card").forEach(c => c.classList.remove("selected")); + el.classList.add("selected"); + importedFormatLabel = null; + document.getElementById("format-pill").classList.add("hidden"); + document.getElementById("custom-area").classList.add("hidden"); + currentTraceId = id; + currentTrace = SAMPLE_TRACES.find(c => c.id === id).trace; + document.getElementById("run-btn").disabled = false; +} + +function toggleCustom() { + const area = document.getElementById("custom-area"); + area.classList.toggle("hidden"); + if (!area.classList.contains("hidden")) { + document.querySelectorAll(".case-card").forEach(c => c.classList.remove("selected")); + currentTraceId = "custom"; + document.getElementById("run-btn").disabled = false; + } +} + +/* ---------- run ---------- */ + +function onRun() { + if (currentTraceId === "custom") { + const raw = document.getElementById("custom-json").value.trim(); + try { + currentTrace = JSON.parse(raw); + } catch (e) { + showToast("Invalid JSON — check the trace and try again."); + return; + } + } + if (!currentTrace) return; + + lastResult = runInvestigation(currentTrace); + const report = composeReport(lastResult, currentTrace); + + document.getElementById("results").classList.remove("hidden"); + paintCausalityGraph("causality-graph", currentTrace, lastResult); + renderRemediation(lastResult); + renderTimeline(lastResult.timeline); + renderReport(report, lastResult); + renderRulePanel(lastResult); + renderTraceViewer(currentTrace); + document.getElementById("results").scrollIntoView({ behavior: "smooth", block: "start" }); +} + +/* ---------- ECG timeline (the signature) ---------- */ + +function renderTimeline(events) { + const host = document.getElementById("timeline-svg"); + const labels = document.getElementById("timeline-labels"); + host.innerHTML = ""; + labels.innerHTML = ""; + if (!events.length) return; + + const W = Math.max(720, events.length * 150); + const H = 120; + const baseY = 74; + const step = W / (events.length + 1); + const failIdx = events.findIndex(e => e.kind === "fail"); + + const colors = { info: "var(--dim)", tool: "var(--blue)", ok: "var(--green)", warn: "var(--amber)", fail: "var(--red)" }; + let path = `M 0 ${baseY}`; + const marks = []; + + events.forEach((e, i) => { + const x = step * (i + 1); + const amp = e.kind === "fail" ? 44 : e.kind === "warn" ? 30 : 22; + if (failIdx !== -1 && i > failIdx) { + // flatline after the failure point + path += ` L ${x} ${baseY}`; + } else { + path += ` L ${x - 16} ${baseY} L ${x - 8} ${baseY - amp} L ${x} ${baseY + (e.kind === "fail" ? 14 : 8)} L ${x + 8} ${baseY}`; + } + marks.push({ x, e, i }); + }); + path += ` L ${W} ${baseY}`; + + const failX = failIdx !== -1 ? step * (failIdx + 1) : null; + + const svg = ` + + + ${failX !== null ? ` + + + + + + + ` : ` + + `} + ${marks.map(m => ` + + ${m.e.ts || ""} + `).join("")} + `; + host.innerHTML = svg; + + events.forEach((e) => { + const chip = document.createElement("div"); + chip.className = `tl-chip tl-${e.kind}`; + chip.innerHTML = `${e.ts || ""}${escapeHtml(e.label)}`; + labels.appendChild(chip); + if (e !== events[events.length - 1]) { + const arrow = document.createElement("span"); + arrow.className = "tl-arrow"; + arrow.textContent = "→"; + labels.appendChild(arrow); + } + }); +} + +function clipPath(p) { return p; } + +/* ---------- report ---------- */ + +function renderReport(report, result) { + const stamp = document.getElementById("stamp"); + const conf = document.getElementById("confidence"); + const confBar = document.getElementById("confidence-bar"); + + if (!report.category) { + stamp.textContent = "NO MATCH"; + stamp.style.setProperty("--stamp-color", "var(--dim)"); + conf.textContent = "—"; + confBar.style.width = "0%"; + } else { + stamp.textContent = report.category.label.toUpperCase(); + stamp.style.setProperty("--stamp-color", report.category.color); + conf.textContent = report.confidence + "%"; + confBar.style.width = report.confidence + "%"; + confBar.style.background = report.category.color; + } + document.getElementById("confidence-note").textContent = + report.category ? `evidence-weighted · base 35 + 0.8 × ${result.primaryPoints} pts, capped at 95` : ""; + + // Evidence + const evWrap = document.getElementById("evidence"); + evWrap.innerHTML = ""; + const primaryFired = result.fired.filter(f => f.rule.category === result.primary); + for (const f of primaryFired) { + evWrap.appendChild(evidenceRow(f)); + } + + document.getElementById("root-cause").textContent = report.rootCause; + document.getElementById("root-cause-mode").textContent = "rule-based narrative · offline"; + + // Contributing factors + const cf = document.getElementById("contributing"); + cf.innerHTML = ""; + if (report.contributing.length === 0) { + cf.innerHTML = `

None — all fired rules point at the primary category.

`; + } else { + for (const c of report.contributing) { + const div = document.createElement("div"); + div.className = "contrib"; + div.innerHTML = ` + ${c.category.label} + +${c.points} pts + ${c.rules.map(escapeHtml).join(" · ")}`; + cf.appendChild(div); + } + } + + fillActionList("fixes", report.fixes); + fillActionList("preventive", report.preventive); +} + +function renderRemediation(result) { + const host = document.getElementById("remediation"); + host.innerHTML = ""; + const deck = adviseRemediation(result); + if (!deck.length) { host.innerHTML = `

Nothing to remediate — no failure detected.

`; return; } + deck.forEach(item => { + const card = document.createElement("div"); + card.className = "remedy" + (item.secondary ? " remedy-secondary" : ""); + card.innerHTML = `${escapeHtml(item.tag)}${escapeHtml(item.why)}`; + host.appendChild(card); + }); +} + +function evidenceRow(f) { + const row = document.createElement("div"); + row.className = "evidence-row"; + const links = f.refs.map(r => + `` + ).join(" "); + row.innerHTML = `${f.rule.id} + ${escapeHtml(f.evidence)} + ${links}`; + row.querySelectorAll(".ref-link").forEach(btn => + btn.addEventListener("click", () => jumpToRef(btn.dataset.ref)) + ); + return row; +} + +function fillActionList(id, items) { + const el = document.getElementById(id); + el.innerHTML = ""; + for (const it of items) { + const li = document.createElement("li"); + li.innerHTML = `${it.ruleId} ${escapeHtml(it.text)}`; + el.appendChild(li); + } +} + +/* ---------- rule engine panel ---------- */ + +function renderRulePanel(result) { + const wrap = document.getElementById("rule-panel"); + wrap.innerHTML = ""; + + // category score bars + const maxPts = Math.max(...Object.values(result.scores), 1); + const bars = document.createElement("div"); + bars.className = "score-bars"; + for (const [key, pts] of Object.entries(result.scores).sort((a, b) => b[1] - a[1])) { + const cat = CATEGORIES[key]; + const row = document.createElement("div"); + row.className = "score-row" + (key === result.primary ? " primary" : ""); + row.innerHTML = `${cat.label} + + ${pts}`; + bars.appendChild(row); + } + wrap.appendChild(bars); + + const title = document.createElement("div"); + title.className = "panel-subtitle"; + title.textContent = `Fired rules (${result.fired.length} of ${RULES.length} in catalog)`; + wrap.appendChild(title); + + for (const f of result.fired) { + const cat = CATEGORIES[f.rule.category]; + const div = document.createElement("div"); + div.className = "fired-rule"; + div.innerHTML = `
+ ${f.rule.id} + ${escapeHtml(f.rule.title)} + +${f.rule.points} +
`; + wrap.appendChild(div); + } +} + +/* ---------- raw trace viewer ---------- */ + +function renderTraceViewer(trace) { + const wrap = document.getElementById("trace-viewer"); + wrap.innerHTML = ""; + + wrap.appendChild(traceSection("System prompt", [ + { id: "prompt-0", text: trace.system_prompt || "(none)" } + ])); + + wrap.appendChild(traceSection("Conversation", (trace.conversation || []).map((m, i) => ({ + id: `msg-${i}`, text: `[${m.ts}] ${m.role}: ${m.content}` + })))); + + wrap.appendChild(traceSection("Tool calls", (trace.tool_calls || []).map((t, i) => ({ + id: `tool-${i}`, + text: `[${t.ts}] ${t.tool}(${JSON.stringify(t.input)}) → ${t.status.toUpperCase()} ${t.duration_ms}ms${t.output ? "\n output: " + t.output : ""}` + })))); + + wrap.appendChild(traceSection("Retrieved docs", (trace.retrieved_docs || []).length + ? trace.retrieved_docs.map((d, i) => ({ + id: `doc-${i}`, text: `${d.id} (${d.source}, score ${d.score}): ${d.content}` + })) + : [{ id: "doc-none", text: "(0 documents retrieved)" }])); + + wrap.appendChild(traceSection("Logs", (trace.logs || []).map((l, i) => ({ + id: `log-${i}`, text: `[${l.ts}] ${l.level.padEnd(5)} ${l.event} — ${l.message}`, level: l.level + })))); + + wrap.appendChild(traceSection("Final response", [ + { id: "response-0", text: `[${trace.final_response?.ts}] ${trace.final_response?.content || ""}` } + ])); +} + +function traceSection(title, lines) { + const sec = document.createElement("div"); + sec.className = "trace-section"; + sec.innerHTML = `
${title}
`; + for (const l of lines) { + const div = document.createElement("div"); + div.className = "trace-line" + (l.level === "ERROR" ? " line-error" : l.level === "WARN" ? " line-warn" : ""); + div.id = "ref-" + l.id; + div.textContent = l.text; + sec.appendChild(div); + } + return sec; +} + +function jumpToRef(ref) { + // evidence refs use type names: prompt, msg, tool, doc, log, response + const map = { prompt: "prompt", tool: "tool", doc: "doc", log: "log", response: "response" }; + const [type, idx] = ref.split("-"); + const el = document.getElementById(`ref-${map[type] || type}-${idx}`); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.remove("flash"); + void el.offsetWidth; // restart animation + el.classList.add("flash"); +} + +/* ---------- optional LLM narrative ---------- */ + +async function onComposeWithClaude() { + const key = document.getElementById("api-key").value.trim(); + const btn = document.getElementById("llm-btn"); + if (!key) { showToast("Paste an Anthropic API key first — or keep using offline mode."); return; } + if (!lastResult || !lastResult.primary) { showToast("Run an investigation first."); return; } + btn.disabled = true; btn.textContent = "Composing…"; + try { + const text = await composeRootCauseWithClaude(key, lastResult, currentTrace); + document.getElementById("root-cause").textContent = text; + document.getElementById("root-cause-mode").textContent = "narrated by Claude · findings unchanged"; + } catch (e) { + showToast("LLM call failed (" + e.message + ") — kept the rule-based narrative."); + } finally { + btn.disabled = false; btn.textContent = "Compose with Claude"; + } +} + +/* ---------- misc ---------- */ + +function showToast(msg) { + const t = document.getElementById("toast"); + t.textContent = msg; + t.classList.add("show"); + setTimeout(() => t.classList.remove("show"), 3200); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} diff --git a/kits/AGENT/agent-failure-investigator/js/compare.js b/kits/AGENT/agent-failure-investigator/js/compare.js new file mode 100644 index 000000000..d4a2a215a --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/js/compare.js @@ -0,0 +1,58 @@ +function diffInvestigations(traceA, traceB) { + const a = runInvestigation(traceA); + const b = runInvestigation(traceB); + + const sumLatency = t => (t.tool_calls || []).reduce((acc, tc) => acc + (tc.duration_ms || 0), 0); + const countBroken = t => (t.tool_calls || []).filter(tc => tc.status !== "success").length; + + const idsA = new Set(a.fired.map(f => f.rule.id)); + const idsB = new Set(b.fired.map(f => f.rule.id)); + + return { + a, b, + resolvedRules: a.fired.filter(f => !idsB.has(f.rule.id)), + introducedRules: b.fired.filter(f => !idsA.has(f.rule.id)), + persistingRules: a.fired.filter(f => idsB.has(f.rule.id)), + metrics: [ + metricRow("Failure category", + a.primary ? CATEGORIES[a.primary].label : "None", + b.primary ? CATEGORIES[b.primary].label : "None", + a.primary && !b.primary ? "better" : !a.primary && b.primary ? "worse" : a.primary === b.primary ? "same" : "changed"), + metricRow("Confidence", a.confidence + "%", b.confidence + "%", + b.confidence < a.confidence ? "better" : b.confidence > a.confidence ? "worse" : "same"), + metricRow("Rules fired", a.fired.length, b.fired.length, + b.fired.length < a.fired.length ? "better" : b.fired.length > a.fired.length ? "worse" : "same"), + metricRow("Tool errors", countBroken(traceA), countBroken(traceB), + countBroken(traceB) < countBroken(traceA) ? "better" : countBroken(traceB) > countBroken(traceA) ? "worse" : "same"), + metricRow("Total tool latency", sumLatency(traceA) + "ms", sumLatency(traceB) + "ms", + sumLatency(traceB) < sumLatency(traceA) ? "better" : sumLatency(traceB) > sumLatency(traceA) ? "worse" : "same") + ] + }; +} + +const metricRow = (name, before, after, verdict) => ({ name, before, after, verdict }); + +function renderComparison(hostId, diff) { + const host = document.getElementById(hostId); + const badge = v => v === "better" ? `▼ Improved` + : v === "worse" ? `▲ Regressed` + : v === "changed" ? `Changed` + : `Unchanged`; + + const ruleList = (items, css, verb) => items.length + ? items.map(f => `
${f.rule.id} ${verb} — ${escapeHtml(f.rule.title)}
`).join("") + : `
none
`; + + host.innerHTML = ` + + + ${diff.metrics.map(m => ``).join("")} +
MetricTrace A (before)Trace B (after)Verdict
${m.name}${m.before}${m.after}${badge(m.verdict)}
+
+
Resolved in B
${ruleList(diff.resolvedRules, "cmp-solved", "Solved")}
+
Still firing
${ruleList(diff.persistingRules, "cmp-open", "Open")}
+
New in B
${ruleList(diff.introducedRules, "cmp-new", "Introduced")}
+
`; +} + +if (typeof module !== "undefined") module.exports = { diffInvestigations }; diff --git a/kits/AGENT/agent-failure-investigator/js/engine.js b/kits/AGENT/agent-failure-investigator/js/engine.js new file mode 100644 index 000000000..644e37f38 --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/js/engine.js @@ -0,0 +1,128 @@ +/** + * Investigation engine. + * + * runInvestigation(trace) executes every rule in the catalog against the + * trace and produces a structured, fully deterministic result: + * + * { + * fired: [ { rule, evidence, refs } ] // rules that matched + * scores: { CATEGORY: points } // evidence weight per category + * primary: CATEGORY key // highest-scoring category + * confidence: 0..95 // evidence-weighted confidence + * timeline: [ { ts, label, kind } ] // reconstructed event sequence + * } + * + * The LLM (js/report.js) only narrates this result. It never decides it. + */ + +/** + * Conflict resolution: when a root-cause rule fires, its downstream + * symptoms are suppressed so they don't outvote the actual cause. + * Example: if the agent called the WRONG tool (R41), then "ignored the + * tool output" (R14) is a consequence of that choice, not independent + * evidence of hallucination — the model was right to distrust an + * irrelevant result. + */ +const SUPPRESSIONS = [ + { when: "R41", suppress: ["R14"] } +]; + +function runInvestigation(trace) { + let fired = []; + for (const rule of RULES) { + let hit = null; + try { hit = rule.test(trace); } catch (e) { /* a broken rule must never kill the run */ } + if (hit) fired.push({ rule, evidence: hit.evidence, refs: hit.refs || [] }); + } + + // conflict resolution pass + const firedIds = new Set(fired.map(f => f.rule.id)); + const suppressed = new Set(); + for (const s of SUPPRESSIONS) { + if (firedIds.has(s.when)) s.suppress.forEach(id => suppressed.add(id)); + } + fired = fired.filter(f => !suppressed.has(f.rule.id)); + + const scores = {}; + for (const key of Object.keys(CATEGORIES)) scores[key] = 0; + for (const f of fired) scores[f.rule.category] += f.rule.points; + + let primary = null, primaryPoints = -1; + for (const [key, pts] of Object.entries(scores)) { + if (pts > primaryPoints) { primary = key; primaryPoints = pts; } + } + if (primaryPoints <= 0) primary = null; + + // Confidence: a floor of 35 plus 0.8 × evidence points, capped at 95. + // Deliberately never 100 — a rule engine reports evidence, not certainty. + const confidence = primary ? Math.min(95, Math.round(35 + primaryPoints * 0.8)) : 0; + + return { + fired, + scores, + primary, + primaryPoints, + confidence, + timeline: buildTimeline(trace, fired, primary) + }; +} + +/* ---------- timeline reconstruction ---------- */ + +function buildTimeline(trace, fired, primary) { + const events = []; + + for (const m of trace.conversation || []) { + if (m.role === "user") events.push({ ts: m.ts, label: "Prompt sent", kind: "info" }); + } + + for (const tc of trace.tool_calls || []) { + events.push({ ts: tc.ts, label: `${tc.tool} invoked`, kind: "tool" }); + if (tc.status === "timeout") { + events.push({ ts: addSeconds(tc.ts, Math.round((tc.duration_ms || 0) / 1000)), label: `${tc.tool} TIMEOUT (${tc.duration_ms}ms)`, kind: "fail" }); + } else if (tc.status === "error") { + events.push({ ts: tc.ts, label: `${tc.tool} ERROR`, kind: "fail" }); + } else { + events.push({ ts: tc.ts, label: `${tc.tool} completed (${tc.duration_ms}ms)`, kind: "ok" }); + } + } + + for (const l of trace.logs || []) { + if (/fallback/i.test(l.event)) events.push({ ts: l.ts, label: "Fallback activated", kind: "warn" }); + if (/retrieval\.empty|0 chunks/i.test(l.event + " " + l.message)) events.push({ ts: l.ts, label: "Retrieval: 0 chunks", kind: "warn" }); + if (/output\.validation/i.test(l.event)) events.push({ ts: l.ts, label: "Output instability detected", kind: "warn" }); + } + + if (trace.final_response) { + const respIsFailure = primary === "HALLUCINATION" || primary === "RAG_FAILURE" || primary === "WRONG_TOOL" || + fired.some(f => f.refs.some(r => r.type === "response")); + events.push({ + ts: trace.final_response.ts, + label: respIsFailure ? failureLabelFor(primary) : "Response generated", + kind: respIsFailure ? "fail" : "ok" + }); + } + + events.sort((a, b) => (a.ts || "").localeCompare(b.ts || "")); + // de-duplicate identical (ts,label) + return events.filter((e, i) => i === 0 || e.ts !== events[i - 1].ts || e.label !== events[i - 1].label); +} + +function failureLabelFor(primary) { + switch (primary) { + case "HALLUCINATION": return "Hallucination started"; + case "RAG_FAILURE": return "Ungrounded answer generated"; + case "WRONG_TOOL": return "Answer built on wrong tool's result"; + case "TOOL_FAILURE": return "Answer generated despite tool failure"; + case "PROMPT_AMBIGUITY": return "Erratic output generated"; + default: return "Response generated"; + } +} + +function addSeconds(ts, s) { + if (!ts || !/^\d{2}:\d{2}:\d{2}$/.test(ts)) return ts; + const [h, m, sec] = ts.split(":").map(Number); + const total = h * 3600 + m * 60 + sec + (s || 0); + const pad = n => String(n).padStart(2, "0"); + return `${pad(Math.floor(total / 3600) % 24)}:${pad(Math.floor(total / 60) % 60)}:${pad(total % 60)}`; +} diff --git a/kits/AGENT/agent-failure-investigator/js/exporter.js b/kits/AGENT/agent-failure-investigator/js/exporter.js new file mode 100644 index 000000000..54e532d53 --- /dev/null +++ b/kits/AGENT/agent-failure-investigator/js/exporter.js @@ -0,0 +1,91 @@ +function investigationToMarkdown(result, report, trace, formatLabel) { + const stamp = new Date().toISOString().slice(0, 16).replace("T", " "); + const cat = report.category ? report.category.label : "No failure detected"; + const lines = []; + const push = (...xs) => lines.push(...xs); + + push(`# Investigation Report`, ``); + push(`| | |`, `|---|---|`); + push(`| Generated | ${stamp} UTC |`); + push(`| Agent | ${trace.meta?.agent || "—"} |`); + push(`| Model | ${trace.meta?.model || "—"} |`); + push(`| Source format | ${formatLabel || "Native"} |`); + push(`| **Failure category** | **${cat}** |`); + push(`| Confidence | ${result.confidence}% |`, ``); + + push(`## Timeline`, ``); + result.timeline.forEach(e => push(`- \`${e.ts || "—"}\` ${e.kind === "fail" ? "🔴" : e.kind === "warn" ? "🟡" : "🟢"} ${e.label}`)); + push(``); + + push(`## Evidence`, ``); + const primaryEvidence = result.fired.filter(f => f.rule.category === result.primary); + if (!primaryEvidence.length) push(`_No rule fired — the trace looks healthy._`); + primaryEvidence.forEach(f => push(`- **[${f.rule.id}]** ${f.evidence}`)); + push(``); + + push(`## Root cause`, ``, report.rootCause || "—", ``); + + if (report.contributing?.length) { + push(`## Contributing factors`, ``); + report.contributing.forEach(c => push(`- ${c.category.label} (+${c.points} pts): ${c.rules.join(", ")}`)); + push(``); + } + + push(`## Recommendations`, ``); + (report.fixes || []).forEach(f => push(`- [${f.ruleId}] ${f.text}`)); + const remediation = typeof adviseRemediation === "function" ? adviseRemediation(result) : []; + if (remediation.length) { + push(``, `### Remediation playbook`, ``); + remediation.forEach(r => push(`- **${r.tag}** — ${r.why}`)); + } + push(``); + + push(`## Prevention`, ``); + (report.preventive || []).forEach(p => push(`- [${p.ruleId}] ${p.text}`)); + push(``, `---`, `_Deterministic diagnosis by Agent Failure Investigator — ${result.fired.length} rule(s) fired out of ${RULES.length} in catalog._`); + + return lines.join("\n"); +} + +function saveMarkdownReport(result, report, trace, formatLabel) { + const md = investigationToMarkdown(result, report, trace, formatLabel); + const blob = new Blob([md], { type: "text/markdown" }); + const a = Object.assign(document.createElement("a"), { + href: URL.createObjectURL(blob), + download: `investigation-${(trace.meta?.flow_id || "trace").replace(/\W+/g, "-")}.md` + }); + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 500); +} + +function printPdfReport(result, report, trace, formatLabel) { + const md = investigationToMarkdown(result, report, trace, formatLabel); + const html = md + .replace(/&/g, "&").replace(//g, ">") + .replace(/^# (.*)$/gm, "

$1

") + .replace(/^### (.*)$/gm, "

$1

") + .replace(/^## (.*)$/gm, "

$1

") + .replace(/^\| ?(.*) ?\|$/gm, row => "" + row.slice(1, -1).split("|").map(c => `${c.trim()}`).join("") + "") + .replace(/([\s\S]*?<\/tr>\n?)+/g, m => /---/.test(m) ? `${m.replace(/
---<\/td>.*?<\/tr>\n?/g, "")}
` : m) + .replace(/^- (.*)$/gm, "
  • $1
  • ") + .replace(/(
  • [\s\S]*?<\/li>\n?)+/g, m => `
      ${m}
    `) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/_(.+?)_/g, "$1") + .replace(/`(.+?)`/g, "$1") + .replace(/^---$/gm, "
    "); + + const w = window.open("", "_blank"); + w.document.write(`Investigation Report${html}