@swestash/agent-lens 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -591,7 +591,7 @@ var CAC = class extends EventEmitter {
591
591
  var cac = (name = "") => new CAC(name);
592
592
 
593
593
  // ../core/dist/schema.js
594
- var SCHEMA_VERSION = 12;
594
+ var SCHEMA_VERSION = 13;
595
595
  var SCHEMA_SQL = (
596
596
  /* sql */
597
597
  `
@@ -712,7 +712,8 @@ CREATE TABLE IF NOT EXISTS tool_calls (
712
712
  workflow_run_id TEXT, -- for Workflow: the run id (toolUseResult.runId, e.g. wf_<id>) \u2014 ties the run to its subagents
713
713
  workflow_name TEXT, -- for Workflow: the workflow name (toolUseResult.workflowName)
714
714
  resolved_model TEXT,
715
- status TEXT, -- success | error | ...
715
+ status TEXT, -- success | error | ... ('error' when the tool_result had is_error)
716
+ error_type TEXT, -- heuristic bucket for status='error' rows (see errors.ts); NULL otherwise
716
717
  total_duration_ms INTEGER,
717
718
  total_tokens INTEGER,
718
719
  total_tool_use_count INTEGER,
@@ -725,6 +726,7 @@ CREATE INDEX IF NOT EXISTS idx_tool_calls_skill ON tool_calls(skill_name);
725
726
  CREATE INDEX IF NOT EXISTS idx_tool_calls_skill_id ON tool_calls(skill_id);
726
727
  CREATE INDEX IF NOT EXISTS idx_tool_calls_spawned ON tool_calls(spawned_session_id);
727
728
  CREATE INDEX IF NOT EXISTS idx_tool_calls_workflow_run ON tool_calls(workflow_run_id);
729
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_error_type ON tool_calls(error_type);
728
730
 
729
731
  -- Skill *versions* (content-addressed). A skill firing injects the full SKILL.md body into the
730
732
  -- transcript as an isMeta user event (begins "Base directory for this skill: \u2026", then the body,
@@ -945,6 +947,35 @@ var SECURITY_CATEGORIES = [
945
947
  }
946
948
  ];
947
949
 
950
+ // ../core/dist/errors.js
951
+ var ERROR_CLASSIFIER_VERSION = 1;
952
+ var REJECTION_TYPES = /* @__PURE__ */ new Set(["user-rejected", "guardrail-blocked"]);
953
+ function errorKind(type) {
954
+ return REJECTION_TYPES.has(type) ? "rejection" : "failure";
955
+ }
956
+ var PATTERNS = [
957
+ // — Rejections / blocks: is_error, but the agent's tool didn't fail — a human or guardrail stopped it.
958
+ { re: /interrupted by user|tool use was rejected|does(?:n['’]| not)t want to proceed|\bCancelled:/i, type: "user-rejected", kind: "rejection" },
959
+ { re: /\bBlocked:/i, type: "guardrail-blocked", kind: "rejection" },
960
+ // — Real tool failures.
961
+ { re: /String to replace not found/i, type: "string-not-found", kind: "failure" },
962
+ { re: /exceeds maximum allowed tokens/i, type: "token-limit", kind: "failure" },
963
+ // A non-zero Bash exit code is a command failure regardless of what its stderr text mentions — check
964
+ // it before the file-state fs-error patterns, since ENOENT/EISDIR also appear inside command stderr
965
+ // (e.g. `Exit code 2 npm error ENOENT`). The file-state patterns then catch the Read/Write/Edit
966
+ // tool_use_errors, which carry no exit code.
967
+ { re: /\bExit code\b/i, type: "command-failed", kind: "failure" },
968
+ { re: /has not been read yet|modified since (?:you |it was )?read|File (?:content )?does not exist|\bEISDIR\b|\bENOENT\b/i, type: "file-state", kind: "failure" }
969
+ ];
970
+ function classifyToolError(resultSummary) {
971
+ const s = resultSummary ?? "";
972
+ for (const p of PATTERNS) {
973
+ if (p.re.test(s))
974
+ return { type: p.type, kind: p.kind };
975
+ }
976
+ return { type: "other", kind: "failure" };
977
+ }
978
+
948
979
  // ../core/dist/pricing.js
949
980
  var PRICE_TABLE = {
950
981
  // Opus 4.0 / 4.1 launched at $15/$75; Opus 4.5 cut the Opus tier to $5/$25 and 4.6/4.7/4.8 kept it.
@@ -2041,8 +2072,13 @@ var ClaudeCodeAdapter = class {
2041
2072
  toolResults.push({
2042
2073
  tool_use_id: b.tool_use_id,
2043
2074
  result_summary: isAskUser ? JSON.stringify({ answers: tur.answers, annotations: tur.annotations ?? {} }) : summarizeResult(b.content),
2075
+ // Tool failure is signalled by `is_error` on the tool_result block itself — NOT by
2076
+ // toolUseResult.status (whose values are task/lifecycle states like completed/pending/
2077
+ // async_launched and never "error"). Map is_error → status="error" so the error roll-ups,
2078
+ // the transcript `tool-err` styling, and detect.ts's failed-attempt de-escalation all fire.
2079
+ // Non-error results fall back to the toolUseResult lifecycle status when present.
2080
+ status: b.is_error === true ? "error" : tur ? asString(tur.status) : null,
2044
2081
  ...tur ? {
2045
- status: asString(tur.status),
2046
2082
  agent_type: asString(tur.agentType),
2047
2083
  // Deterministic link: a Task/Agent subagent's transcript is keyed off its agentId
2048
2084
  // ('agent-'||agentId is the session id assigned by discover()'s filename stem).
@@ -2832,6 +2868,33 @@ function detect(db, dirty) {
2832
2868
  return { count, version: DETECTOR_VERSION };
2833
2869
  }
2834
2870
 
2871
+ // ../ingest/dist/errors.js
2872
+ function classifyErrors(db, dirty) {
2873
+ const incremental = dirty != null;
2874
+ const scope = incremental ? " AND session_id IN (SELECT id FROM _dirty_err)" : "";
2875
+ if (incremental) {
2876
+ db.exec("DROP TABLE IF EXISTS _dirty_err");
2877
+ db.exec("CREATE TEMP TABLE _dirty_err (id TEXT PRIMARY KEY)");
2878
+ const ins = db.prepare("INSERT OR IGNORE INTO _dirty_err (id) VALUES (?)");
2879
+ db.transaction((ids) => {
2880
+ for (const id of ids)
2881
+ ins.run(id);
2882
+ })(dirty);
2883
+ }
2884
+ const rows = db.prepare(`SELECT id, result_summary FROM tool_calls WHERE status = 'error'${scope}`).all();
2885
+ const update = db.prepare("UPDATE tool_calls SET error_type = ? WHERE id = ?");
2886
+ const tx = db.transaction(() => {
2887
+ db.exec(`UPDATE tool_calls SET error_type = NULL WHERE error_type IS NOT NULL${scope}`);
2888
+ for (const r of rows)
2889
+ update.run(classifyToolError(r.result_summary).type, r.id);
2890
+ });
2891
+ tx();
2892
+ if (incremental)
2893
+ db.exec("DROP TABLE IF EXISTS _dirty_err");
2894
+ const count = db.prepare("SELECT COUNT(*) n FROM tool_calls WHERE error_type IS NOT NULL").get().n;
2895
+ return { count, version: ERROR_CLASSIFIER_VERSION };
2896
+ }
2897
+
2835
2898
  // ../ingest/dist/pipeline.js
2836
2899
  import { createHash as createHash2 } from "crypto";
2837
2900
  function newStats() {
@@ -3767,6 +3830,7 @@ function runIngest(argv = process.argv.slice(2)) {
3767
3830
  const expanded = rebuildDerived(db, args.full ? null : dirty);
3768
3831
  const classified = classify(db, args.full ? null : expanded);
3769
3832
  const detected = detect(db, args.full ? null : expanded);
3833
+ classifyErrors(db, args.full ? null : expanded);
3770
3834
  const count = (sql) => db.prepare(sql).get().n;
3771
3835
  const sessions = count("SELECT COUNT(*) n FROM sessions");
3772
3836
  const turns = count("SELECT COUNT(*) n FROM turns");
@@ -3808,8 +3872,9 @@ function runMetrics(argv = process.argv.slice(2)) {
3808
3872
  const db = openDb(dbPath);
3809
3873
  const r = classify(db);
3810
3874
  const d = detect(db);
3875
+ const e = classifyErrors(db);
3811
3876
  db.close();
3812
- console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} db=${dbPath}`);
3877
+ console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} errors_classified=${e.count} error_classifier_version=${e.version} db=${dbPath}`);
3813
3878
  }
3814
3879
 
3815
3880
  // ../server/dist/run.js
@@ -3889,6 +3954,14 @@ function listSessions(db, f) {
3889
3954
  where.push("s.is_sidechain = 0");
3890
3955
  if (f.kind === "subagent")
3891
3956
  where.push("s.is_sidechain = 1");
3957
+ if (f.severity?.length) {
3958
+ where.push(`EXISTS (SELECT 1 FROM findings fd WHERE fd.session_id = s.id AND fd.severity IN (${f.severity.map(() => "?").join(",")}))`);
3959
+ params.push(...f.severity);
3960
+ }
3961
+ if (f.errorType?.length) {
3962
+ where.push(`EXISTS (SELECT 1 FROM tool_calls tc WHERE tc.session_id = s.id AND tc.error_type IN (${f.errorType.map(() => "?").join(",")}))`);
3963
+ params.push(...f.errorType);
3964
+ }
3892
3965
  if (f.model) {
3893
3966
  where.push("EXISTS (SELECT 1 FROM token_usage t WHERE t.session_id = s.id AND t.model = ?)");
3894
3967
  params.push(f.model);
@@ -3904,7 +3977,13 @@ function listSessions(db, f) {
3904
3977
  const total = db.prepare(`SELECT COUNT(*) n FROM sessions s ${whereSql}`).get(...params).n;
3905
3978
  const baseSelect = `SELECT s.id, s.ai_title, s.slug, s.source_id, s.is_sidechain, s.started_at, s.ended_at,
3906
3979
  s.duration_ms, s.event_count, s.turn_count, p.path AS project_path,
3907
- (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models
3980
+ (SELECT GROUP_CONCAT(DISTINCT model) FROM token_usage t WHERE t.session_id = s.id) AS models,
3981
+ (SELECT COUNT(*) FROM tool_calls tc WHERE tc.session_id = s.id) AS tool_call_count,
3982
+ (SELECT COUNT(*) FROM tool_calls tc WHERE tc.session_id = s.id AND tc.status = 'error') AS tool_error_count,
3983
+ (SELECT COUNT(*) FROM findings fd WHERE fd.session_id = s.id) AS finding_count,
3984
+ (SELECT fd.severity FROM findings fd WHERE fd.session_id = s.id
3985
+ ORDER BY CASE fd.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 WHEN 'info' THEN 0 ELSE -1 END DESC
3986
+ LIMIT 1) AS worst_severity
3908
3987
  FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
3909
3988
  ${whereSql}`;
3910
3989
  const NATIVE_ORDER = {
@@ -3915,13 +3994,29 @@ function listSessions(db, f) {
3915
3994
  };
3916
3995
  const sortKey = f.sort ?? "started";
3917
3996
  const sqlDir = f.dir === "asc" ? "ASC" : "DESC";
3997
+ const JS_SORT = /* @__PURE__ */ new Set(["tokens", "cost", "errors", "security"]);
3918
3998
  let rows;
3919
- if (sortKey === "tokens" || sortKey === "cost") {
3999
+ if (JS_SORT.has(sortKey)) {
3920
4000
  const all = db.prepare(baseSelect).all(...params);
3921
4001
  attachSessionCost(db, all);
3922
4002
  const sign = f.dir === "asc" ? 1 : -1;
4003
+ const metric = (r) => {
4004
+ switch (sortKey) {
4005
+ case "tokens":
4006
+ return r.tokens;
4007
+ case "cost":
4008
+ return r.cost;
4009
+ case "errors":
4010
+ return r.tool_error_count ?? 0;
4011
+ // Security: rank by worst severity (info=0 … critical=4; none = -1), finding count as tiebreak.
4012
+ case "security":
4013
+ return (severityRank(r.worst_severity ?? "") + 1) * 1e6 + (r.finding_count ?? 0);
4014
+ default:
4015
+ return 0;
4016
+ }
4017
+ };
3923
4018
  all.sort((a, b) => {
3924
- const c = (sortKey === "tokens" ? a.tokens - b.tokens : a.cost - b.cost) * sign;
4019
+ const c = (metric(a) - metric(b)) * sign;
3925
4020
  return c !== 0 ? c : String(a.id).localeCompare(String(b.id));
3926
4021
  });
3927
4022
  rows = all.slice(f.offset, f.offset + f.limit);
@@ -3976,7 +4071,7 @@ function getSession(db, id) {
3976
4071
  const eventRows = db.prepare(`SELECT uuid, type, role, timestamp, model, is_sidechain, turn_id, raw_json
3977
4072
  FROM events WHERE session_id = ? ORDER BY timestamp, seq`).all(id);
3978
4073
  const toolRows = db.prepare(`SELECT id, event_uuid, tool_name, skill_name, skill_id, agent_type, spawned_session_id,
3979
- workflow_run_id, workflow_name, status,
4074
+ workflow_run_id, workflow_name, status, error_type,
3980
4075
  total_duration_ms, total_tokens, input_json, result_summary,
3981
4076
  (SELECT COUNT(*) FROM sessions s WHERE s.workflow_run_id = tool_calls.workflow_run_id) AS workflow_agent_count
3982
4077
  FROM tool_calls WHERE session_id = ?`).all(id);
@@ -4106,6 +4201,10 @@ function getSession(db, id) {
4106
4201
  FROM tool_calls tc LEFT JOIN turns t ON t.id = tc.turn_id
4107
4202
  WHERE tc.session_id = ? AND tc.workflow_run_id IS NOT NULL
4108
4203
  ORDER BY t.seq`).all(id);
4204
+ session.tool_call_count = toolRows.length;
4205
+ session.tool_error_count = toolRows.filter((t) => t.status === "error").length;
4206
+ session.tool_rejection_count = toolRows.filter((t) => t.status === "error" && t.error_type && errorKind(t.error_type) === "rejection").length;
4207
+ session.tool_failure_count = session.tool_error_count - session.tool_rejection_count;
4109
4208
  return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings };
4110
4209
  }
4111
4210
  function xmlTag(text, tag) {
@@ -4456,7 +4555,7 @@ function dashboardTimeseries(db, f, bucketParam) {
4456
4555
  const get = (b) => {
4457
4556
  let r = byBucket.get(b);
4458
4557
  if (!r) {
4459
- r = { bucket: b, ...zeroSplit(), cost: 0, sessions: 0, turns: 0 };
4558
+ r = { bucket: b, ...zeroSplit(), cost: 0, sessions: 0, turns: 0, failures: 0, rejections: 0 };
4460
4559
  byBucket.set(b, r);
4461
4560
  }
4462
4561
  return r;
@@ -4479,6 +4578,16 @@ function dashboardTimeseries(db, f, bucketParam) {
4479
4578
  if (t.b)
4480
4579
  get(t.b).turns = t.n;
4481
4580
  }
4581
+ for (const e of db.prepare(`SELECT ${expr} b, tc.error_type et, COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
4582
+ ${w.sql ? w.sql + " AND" : "WHERE"} tc.status = 'error' GROUP BY b, tc.error_type`).all(...w.params)) {
4583
+ if (!e.b)
4584
+ continue;
4585
+ const r = get(e.b);
4586
+ if (e.et && errorKind(e.et) === "rejection")
4587
+ r.rejections += e.n;
4588
+ else
4589
+ r.failures += e.n;
4590
+ }
4482
4591
  const series = [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
4483
4592
  for (const r of series)
4484
4593
  r.cost = Number(r.cost.toFixed(4));
@@ -4529,7 +4638,20 @@ function dashboardBreakdowns(db, f) {
4529
4638
  max_per_session: counts.length ? Math.max(...counts) : 0,
4530
4639
  avg_per_session: counts.length ? Number((counts.reduce((a, b) => a + b, 0) / counts.length).toFixed(2)) : 0
4531
4640
  };
4532
- return { by_model: byModel, by_source: bySource, by_category: byCategory, by_complexity: byComplexity, tools, skills, skill_versions: skillVersions, subagent_fanout: subagentFanout };
4641
+ const errorRows = db.prepare(`SELECT COALESCE(tc.error_type, 'other') type, COUNT(*) n FROM tool_calls tc JOIN sessions s ON s.id = tc.session_id
4642
+ ${w.sql ? w.sql + " AND" : "WHERE"} tc.status = 'error' GROUP BY tc.error_type ORDER BY n DESC`).all(...w.params);
4643
+ let failures = 0;
4644
+ let rejections = 0;
4645
+ const byType = errorRows.map((r) => {
4646
+ const kind = errorKind(r.type);
4647
+ if (kind === "rejection")
4648
+ rejections += r.n;
4649
+ else
4650
+ failures += r.n;
4651
+ return { type: r.type, kind, n: r.n };
4652
+ });
4653
+ const errorTypes = { by_type: byType, failures, rejections };
4654
+ return { by_model: byModel, by_source: bySource, by_category: byCategory, by_complexity: byComplexity, tools, skills, skill_versions: skillVersions, subagent_fanout: subagentFanout, error_types: errorTypes };
4533
4655
  }
4534
4656
 
4535
4657
  // ../server/dist/refresh.js
@@ -4691,7 +4813,9 @@ async function createApp(db, opts = {}) {
4691
4813
  from: q.from,
4692
4814
  to: q.to,
4693
4815
  kind: q.kind === "main" || q.kind === "subagent" ? q.kind : void 0,
4694
- sort: ["started", "title", "turns", "tokens", "cost", "duration"].find((s) => s === q.sort),
4816
+ severity: q.severity ? q.severity.split(",").filter(Boolean) : void 0,
4817
+ errorType: q.error_type ? q.error_type.split(",").filter(Boolean) : void 0,
4818
+ sort: ["started", "title", "turns", "tokens", "cost", "duration", "errors", "security"].find((s) => s === q.sort),
4695
4819
  dir: q.dir === "asc" ? "asc" : q.dir === "desc" ? "desc" : void 0,
4696
4820
  limit: Math.min(Number(q.limit) || 50, 200),
4697
4821
  offset: Number(q.offset) || 0