failstep 0.1.0__py3-none-any.whl

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.
failstep/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """failstep: lint one finished agent run from a file."""
2
+
3
+ __version__ = "0.1.0"
failstep/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from failstep.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
failstep/adapters.py ADDED
@@ -0,0 +1,628 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ def adapt(data: Any) -> dict[str, Any] | None:
8
+ if isinstance(data, dict) and _is_openai(data):
9
+ payload = _openai(data)
10
+ elif isinstance(data, dict) and _is_langchain(data):
11
+ payload = _langchain(data)
12
+ elif _is_otel(data):
13
+ payload = _otel(data)
14
+ else:
15
+ return None
16
+ steps = payload.get("steps")
17
+ if not isinstance(steps, list) or not steps:
18
+ return None
19
+ return payload
20
+
21
+
22
+ def is_otel_payload(data: Any) -> bool:
23
+ return _is_otel(data)
24
+
25
+
26
+ def _is_openai(data: dict[str, Any]) -> bool:
27
+ messages = data.get("messages")
28
+ if not isinstance(messages, list) or not messages:
29
+ return False
30
+ return any(isinstance(item, dict) and "role" in item for item in messages)
31
+
32
+
33
+ def _is_langchain(data: dict[str, Any]) -> bool:
34
+ steps = data.get("intermediate_steps")
35
+ return isinstance(steps, list) and bool(steps)
36
+
37
+
38
+ def _openai(data: dict[str, Any]) -> dict[str, Any]:
39
+ steps: list[dict[str, Any]] = []
40
+ pending: dict[str, dict[str, Any]] = {}
41
+ for message in data.get("messages") or []:
42
+ if not isinstance(message, dict):
43
+ continue
44
+ role = message.get("role")
45
+ if role == "assistant":
46
+ tool_calls = message.get("tool_calls") or []
47
+ content = message.get("content")
48
+ if content and not tool_calls:
49
+ steps.append(
50
+ {
51
+ "type": "llm",
52
+ "name": "assistant",
53
+ "output": content,
54
+ }
55
+ )
56
+ if not isinstance(tool_calls, list):
57
+ continue
58
+ for call in tool_calls:
59
+ if not isinstance(call, dict):
60
+ continue
61
+ fn = (
62
+ call.get("function")
63
+ if isinstance(call.get("function"), dict)
64
+ else {}
65
+ )
66
+ args: Any = fn.get("arguments", {})
67
+ if isinstance(args, str):
68
+ try:
69
+ args = json.loads(args)
70
+ except json.JSONDecodeError:
71
+ pass
72
+ step = {
73
+ "id": call.get("id"),
74
+ "type": "tool",
75
+ "name": fn.get("name") or "tool",
76
+ "input": args,
77
+ }
78
+ call_id = call.get("id")
79
+ if isinstance(call_id, str):
80
+ pending[call_id] = step
81
+ steps.append(step)
82
+ elif role == "tool":
83
+ output = _maybe_json(message.get("content"))
84
+ error = message.get("error")
85
+ if not (isinstance(error, str) and error):
86
+ error = None
87
+ if error is None and isinstance(output, str) and _looks_error(output):
88
+ error = output
89
+ output = None
90
+ call_id = message.get("tool_call_id")
91
+ target = pending.get(call_id) if isinstance(call_id, str) else None
92
+ if target is not None:
93
+ target["output"] = output
94
+ if error:
95
+ target["error"] = error
96
+ else:
97
+ steps.append(
98
+ {
99
+ "type": "tool",
100
+ "name": message.get("name") or "tool",
101
+ "output": output,
102
+ "error": error,
103
+ }
104
+ )
105
+ run_id = data.get("run_id") or data.get("id") or "openai-run"
106
+ if not isinstance(run_id, str):
107
+ run_id = "openai-run"
108
+ return {
109
+ "run_id": run_id,
110
+ "status": data.get("status") or "unknown",
111
+ "steps": steps,
112
+ "tokens_in": data.get("tokens_in"),
113
+ "tokens_out": data.get("tokens_out"),
114
+ "duration_ms": data.get("duration_ms"),
115
+ }
116
+
117
+
118
+ def _langchain(data: dict[str, Any]) -> dict[str, Any]:
119
+ steps: list[dict[str, Any]] = []
120
+ for item in data.get("intermediate_steps") or []:
121
+ action, observation = _pair(item)
122
+ tool = action.get("tool") or action.get("name") or "tool"
123
+ tool_input = action.get("tool_input", action.get("input"))
124
+ error = None
125
+ output: Any = observation
126
+ if isinstance(observation, str) and _looks_error(observation):
127
+ error = observation
128
+ output = None
129
+ steps.append(
130
+ {
131
+ "type": "tool",
132
+ "name": tool,
133
+ "input": tool_input,
134
+ "output": output,
135
+ "error": error,
136
+ }
137
+ )
138
+ final = data.get("output")
139
+ if final is not None:
140
+ steps.append({"type": "llm", "name": "output", "output": final})
141
+ run_id = data.get("run_id") or "langchain-run"
142
+ if not isinstance(run_id, str):
143
+ run_id = "langchain-run"
144
+ return {
145
+ "run_id": run_id,
146
+ "status": data.get("status") or "unknown",
147
+ "steps": steps,
148
+ "tokens_in": data.get("tokens_in"),
149
+ "tokens_out": data.get("tokens_out"),
150
+ "duration_ms": data.get("duration_ms"),
151
+ }
152
+
153
+
154
+ def _pair(item: Any) -> tuple[dict[str, Any], Any]:
155
+ if isinstance(item, (list, tuple)) and len(item) >= 2:
156
+ action, observation = item[0], item[1]
157
+ elif isinstance(item, dict) and "action" in item:
158
+ action, observation = item.get("action"), item.get("observation")
159
+ else:
160
+ action, observation = item, None
161
+ if not isinstance(action, dict):
162
+ action = {"tool": str(action)}
163
+ return action, observation
164
+
165
+
166
+ def _maybe_json(value: Any) -> Any:
167
+ if not isinstance(value, str):
168
+ return value
169
+ text = value.strip()
170
+ if text[:1] in "{[":
171
+ try:
172
+ return json.loads(text)
173
+ except json.JSONDecodeError:
174
+ return value
175
+ return value
176
+
177
+
178
+ def _looks_error(text: str) -> bool:
179
+ head = text[:80].lower()
180
+ return "error" in head or "traceback" in head or "exception" in head
181
+
182
+
183
+ _LLM_OPS = {"chat", "generate_content", "text_completion"}
184
+ _TOOL_OPS = {"execute_tool"}
185
+ _RETRIEVAL_OPS = {"retrieval"}
186
+ _WRAPPER_OPS = {"invoke_agent", "invoke_workflow", "create_agent"}
187
+ _ERROR_STATUS = {2, "2", "STATUS_CODE_ERROR", "ERROR", "error"}
188
+
189
+
190
+ def _is_otel(data: Any) -> bool:
191
+ return any(_has_gen_ai(span) for span in _collect_spans(data))
192
+
193
+
194
+ def _has_gen_ai(span: dict[str, Any]) -> bool:
195
+ attrs = _attr_map(span)
196
+ return any(key.startswith("gen_ai.") for key in attrs)
197
+
198
+
199
+ def _collect_spans(data: Any) -> list[dict[str, Any]]:
200
+ if isinstance(data, list):
201
+ if data and all(isinstance(item, dict) for item in data):
202
+ if any(_looks_like_otel_span(item) for item in data):
203
+ return [item for item in data if isinstance(item, dict)]
204
+ return []
205
+ if not isinstance(data, dict):
206
+ return []
207
+ for key in ("resourceSpans", "resource_spans"):
208
+ blocks = data.get(key)
209
+ if not isinstance(blocks, list):
210
+ continue
211
+ out: list[dict[str, Any]] = []
212
+ for block in blocks:
213
+ if not isinstance(block, dict):
214
+ continue
215
+ for scope_key in ("scopeSpans", "scope_spans"):
216
+ scopes = block.get(scope_key) or []
217
+ if not isinstance(scopes, list):
218
+ continue
219
+ for scope in scopes:
220
+ if not isinstance(scope, dict):
221
+ continue
222
+ spans = scope.get("spans")
223
+ if isinstance(spans, list):
224
+ out.extend(
225
+ item for item in spans if isinstance(item, dict)
226
+ )
227
+ if out:
228
+ return out
229
+ spans = data.get("spans")
230
+ if isinstance(spans, list) and any(
231
+ isinstance(item, dict) and _looks_like_otel_span(item) for item in spans
232
+ ):
233
+ return [item for item in spans if isinstance(item, dict)]
234
+ steps = data.get("steps")
235
+ if (
236
+ isinstance(steps, list)
237
+ and steps
238
+ and all(
239
+ isinstance(item, dict) and _looks_like_otel_span(item)
240
+ for item in steps
241
+ )
242
+ ):
243
+ return [item for item in steps if isinstance(item, dict)]
244
+ if _looks_like_otel_span(data):
245
+ return [data]
246
+ return []
247
+
248
+
249
+ def _looks_like_otel_span(item: dict[str, Any]) -> bool:
250
+ attrs = _attr_map(item)
251
+ if any(key.startswith("gen_ai.") for key in attrs):
252
+ return True
253
+ if item.get("spanId") or item.get("span_id") or item.get("traceId"):
254
+ if "attributes" in item or "startTimeUnixNano" in item:
255
+ return True
256
+ context = item.get("context")
257
+ if isinstance(context, dict) and (
258
+ context.get("span_id") or context.get("trace_id")
259
+ ):
260
+ if "attributes" in item:
261
+ return True
262
+ return False
263
+
264
+
265
+ def _otel(data: Any) -> dict[str, Any]:
266
+ spans = _collect_spans(data)
267
+ nanos = [_start_nano(span) for span in spans]
268
+ if nanos and all(value is not None for value in nanos):
269
+ order = sorted(range(len(spans)), key=lambda i: (nanos[i], i))
270
+ else:
271
+ order = list(range(len(spans)))
272
+
273
+ steps: list[dict[str, Any]] = []
274
+ for index in order:
275
+ mapped = _span_to_step(spans[index])
276
+ if mapped is not None:
277
+ steps.append(mapped)
278
+
279
+ tokens_in = _sum_int(step.get("tokens_in") for step in steps)
280
+ tokens_out = _sum_int(step.get("tokens_out") for step in steps)
281
+ duration_ms = _run_duration_ms(spans, steps)
282
+ status = "failed" if any(step.get("error") for step in steps) else "success"
283
+ if status == "success":
284
+ if any(_is_error_status(_status_code(span)) for span in spans):
285
+ status = "failed"
286
+ run_id = _run_id(data, steps)
287
+ return {
288
+ "run_id": run_id,
289
+ "status": status,
290
+ "steps": steps,
291
+ "tokens_in": tokens_in,
292
+ "tokens_out": tokens_out,
293
+ "duration_ms": duration_ms,
294
+ }
295
+
296
+
297
+ def _span_to_step(span: dict[str, Any]) -> dict[str, Any] | None:
298
+ attrs = _attr_map(span)
299
+ op = attrs.get("gen_ai.operation.name")
300
+ if not isinstance(op, str) or not op:
301
+ op = _op_from_name(span.get("name"))
302
+ if not op and "gen_ai.tool.name" in attrs:
303
+ op = "execute_tool"
304
+ if not op and "gen_ai.request.model" in attrs:
305
+ op = "chat"
306
+ if not op and not any(key.startswith("gen_ai.") for key in attrs):
307
+ return None
308
+
309
+ if op in _TOOL_OPS:
310
+ step_type = "tool"
311
+ name = attrs.get("gen_ai.tool.name") or _name_tail(
312
+ span.get("name"), "tool"
313
+ )
314
+ incoming = _maybe_json(attrs.get("gen_ai.tool.call.arguments"))
315
+ outgoing = _maybe_json(attrs.get("gen_ai.tool.call.result"))
316
+ schema = _tool_schema(attrs, name if isinstance(name, str) else "")
317
+ elif op in _LLM_OPS:
318
+ step_type = "llm"
319
+ name = attrs.get("gen_ai.request.model") or _name_tail(
320
+ span.get("name"), "chat"
321
+ )
322
+ incoming = _maybe_json(attrs.get("gen_ai.input.messages"))
323
+ outgoing = _maybe_json(attrs.get("gen_ai.output.messages"))
324
+ schema = None
325
+ elif op in _RETRIEVAL_OPS:
326
+ step_type = "retrieval"
327
+ name = attrs.get("gen_ai.tool.name") or _name_tail(
328
+ span.get("name"), "retrieval"
329
+ )
330
+ incoming = attrs.get("gen_ai.retrieval.query.text")
331
+ if incoming is not None:
332
+ incoming = {"query": incoming}
333
+ outgoing = _maybe_json(attrs.get("gen_ai.retrieval.documents"))
334
+ schema = None
335
+ else:
336
+ step_type = "other"
337
+ name = attrs.get("gen_ai.agent.name") or _name_tail(
338
+ span.get("name"), op or "span"
339
+ )
340
+ incoming = _maybe_json(attrs.get("gen_ai.input.messages"))
341
+ outgoing = _maybe_json(attrs.get("gen_ai.output.messages"))
342
+ schema = None
343
+
344
+ if not isinstance(name, str) or not name:
345
+ name = op or "span"
346
+
347
+ error = _span_error(span, attrs)
348
+ latency = None if op in _WRAPPER_OPS else _latency_ms(span)
349
+ step: dict[str, Any] = {
350
+ "id": _span_id(span),
351
+ "type": step_type,
352
+ "name": name,
353
+ "input": incoming,
354
+ "output": outgoing,
355
+ "error": error,
356
+ "latency_ms": latency,
357
+ "tokens_in": _as_int(attrs.get("gen_ai.usage.input_tokens")),
358
+ "tokens_out": _as_int(attrs.get("gen_ai.usage.output_tokens")),
359
+ }
360
+ if schema is not None:
361
+ step["schema"] = schema
362
+ parent = _parent_id(span)
363
+ meta: dict[str, Any] = {}
364
+ if parent:
365
+ meta["parent_id"] = parent
366
+ if op:
367
+ meta["gen_ai.operation.name"] = op
368
+ if meta:
369
+ step["metadata"] = meta
370
+ return step
371
+
372
+
373
+ def _tool_schema(attrs: dict[str, Any], tool_name: str) -> dict[str, Any] | None:
374
+ raw = attrs.get("gen_ai.tool.definitions")
375
+ raw = _maybe_json(raw)
376
+ if isinstance(raw, dict):
377
+ raw = [raw]
378
+ if not isinstance(raw, list):
379
+ return None
380
+ for item in raw:
381
+ if not isinstance(item, dict):
382
+ continue
383
+ if tool_name and item.get("name") not in {None, tool_name}:
384
+ continue
385
+ params = item.get("parameters") or item.get("schema")
386
+ if isinstance(params, dict) and (
387
+ "required" in params or "properties" in params
388
+ ):
389
+ return params
390
+ if "required" in item or "properties" in item:
391
+ return item
392
+ return None
393
+
394
+
395
+ def _span_error(span: dict[str, Any], attrs: dict[str, Any]) -> str | None:
396
+ status = span.get("status") if isinstance(span.get("status"), dict) else {}
397
+ message = status.get("message") or status.get("description")
398
+ if isinstance(message, str) and message.strip():
399
+ return message
400
+ for key in ("exception.message", "error.type", "error.message"):
401
+ value = attrs.get(key)
402
+ if isinstance(value, str) and value.strip():
403
+ return value
404
+ events = span.get("events")
405
+ if isinstance(events, list):
406
+ for event in events:
407
+ if not isinstance(event, dict):
408
+ continue
409
+ name = event.get("name") or event.get("event_name")
410
+ event_attrs = _attr_map(event) if "attributes" in event else event
411
+ if name == "exception" or event_attrs.get("exception.message"):
412
+ text = event_attrs.get("exception.message")
413
+ if isinstance(text, str) and text.strip():
414
+ return text
415
+ return None
416
+
417
+
418
+ def _status_code(span: dict[str, Any]) -> Any:
419
+ status = span.get("status")
420
+ if not isinstance(status, dict):
421
+ return None
422
+ if "code" in status:
423
+ return status.get("code")
424
+ return status.get("status_code")
425
+
426
+
427
+ def _is_error_status(code: Any) -> bool:
428
+ return code in _ERROR_STATUS
429
+
430
+
431
+ def _attr_map(item: dict[str, Any]) -> dict[str, Any]:
432
+ raw = item.get("attributes")
433
+ if isinstance(raw, dict):
434
+ mapped: dict[str, Any] = {}
435
+ for key, value in raw.items():
436
+ mapped[str(key)] = (
437
+ _maybe_json(value) if isinstance(value, str) else value
438
+ )
439
+ return mapped
440
+ if not isinstance(raw, list):
441
+ return {}
442
+ out: dict[str, Any] = {}
443
+ for entry in raw:
444
+ if not isinstance(entry, dict):
445
+ continue
446
+ key = entry.get("key")
447
+ if not isinstance(key, str):
448
+ continue
449
+ out[key] = _otel_value(entry.get("value"))
450
+ return out
451
+
452
+
453
+ def _otel_value(value: Any) -> Any:
454
+ if not isinstance(value, dict):
455
+ return _maybe_json(value) if isinstance(value, str) else value
456
+ if "stringValue" in value:
457
+ return _maybe_json(value["stringValue"])
458
+ if "intValue" in value:
459
+ return _as_int(value["intValue"])
460
+ if "doubleValue" in value:
461
+ return value["doubleValue"]
462
+ if "boolValue" in value:
463
+ return value["boolValue"]
464
+ if "arrayValue" in value and isinstance(value["arrayValue"], dict):
465
+ values = value["arrayValue"].get("values") or []
466
+ if isinstance(values, list):
467
+ return [_otel_value(item) for item in values]
468
+ if "kvlistValue" in value and isinstance(value["kvlistValue"], dict):
469
+ values = value["kvlistValue"].get("values") or []
470
+ mapped: dict[str, Any] = {}
471
+ if isinstance(values, list):
472
+ for entry in values:
473
+ if isinstance(entry, dict) and isinstance(entry.get("key"), str):
474
+ mapped[entry["key"]] = _otel_value(entry.get("value"))
475
+ return mapped
476
+ return value
477
+
478
+
479
+ def _span_id(span: dict[str, Any]) -> str | None:
480
+ for key in ("spanId", "span_id"):
481
+ value = span.get(key)
482
+ if isinstance(value, str) and value:
483
+ return value
484
+ context = span.get("context")
485
+ if isinstance(context, dict):
486
+ value = context.get("span_id") or context.get("spanId")
487
+ if isinstance(value, str) and value:
488
+ return value
489
+ return None
490
+
491
+
492
+ def _parent_id(span: dict[str, Any]) -> str | None:
493
+ for key in ("parentSpanId", "parent_span_id", "parent_id"):
494
+ value = span.get(key)
495
+ if isinstance(value, str) and value:
496
+ return value
497
+ return None
498
+
499
+
500
+ def _start_nano(span: dict[str, Any]) -> int | None:
501
+ for key in ("startTimeUnixNano", "start_time_unix_nano", "start_time"):
502
+ nano = _as_nano(span.get(key))
503
+ if nano is not None:
504
+ return nano
505
+ return None
506
+
507
+
508
+ def _end_nano(span: dict[str, Any]) -> int | None:
509
+ for key in ("endTimeUnixNano", "end_time_unix_nano", "end_time"):
510
+ nano = _as_nano(span.get(key))
511
+ if nano is not None:
512
+ return nano
513
+ return None
514
+
515
+
516
+ def _as_nano(value: Any) -> int | None:
517
+ number = _as_int(value)
518
+ if number is not None:
519
+ return number
520
+ return None
521
+
522
+
523
+ def _latency_ms(span: dict[str, Any]) -> int | None:
524
+ start = _start_nano(span)
525
+ end = _end_nano(span)
526
+ if start is None or end is None or end < start:
527
+ return None
528
+ return (end - start) // 1_000_000
529
+
530
+
531
+ def _run_duration_ms(
532
+ spans: list[dict[str, Any]], steps: list[dict[str, Any]]
533
+ ) -> int | None:
534
+ starts = [_start_nano(span) for span in spans]
535
+ ends = [_end_nano(span) for span in spans]
536
+ present = [
537
+ (start, end)
538
+ for start, end in zip(starts, ends, strict=True)
539
+ if start is not None and end is not None
540
+ ]
541
+ if present:
542
+ first = min(start for start, _end in present)
543
+ last = max(end for _start, end in present)
544
+ return (last - first) // 1_000_000
545
+ latencies = [
546
+ step.get("latency_ms")
547
+ for step in steps
548
+ if isinstance(step.get("latency_ms"), int)
549
+ ]
550
+ if latencies:
551
+ return max(latencies)
552
+ return None
553
+
554
+
555
+ def _run_id(data: Any, steps: list[dict[str, Any]]) -> str:
556
+ if isinstance(data, dict):
557
+ for key in ("run_id", "id"):
558
+ value = data.get(key)
559
+ if isinstance(value, str) and value:
560
+ return value
561
+ for block_key in ("resourceSpans", "resource_spans"):
562
+ blocks = data.get(block_key)
563
+ if not isinstance(blocks, list):
564
+ continue
565
+ for block in blocks:
566
+ if not isinstance(block, dict):
567
+ continue
568
+ resource = block.get("resource")
569
+ if not isinstance(resource, dict):
570
+ continue
571
+ attrs = _attr_map(resource)
572
+ name = attrs.get("service.name")
573
+ if isinstance(name, str) and name:
574
+ return name
575
+ for step in steps:
576
+ if step.get("type") == "other" and step.get("name"):
577
+ return str(step["name"])
578
+ return "otel-run"
579
+
580
+
581
+ def _op_from_name(name: Any) -> str | None:
582
+ if not isinstance(name, str) or not name:
583
+ return None
584
+ head = name.split()[0]
585
+ known = _LLM_OPS | _TOOL_OPS | _RETRIEVAL_OPS | {
586
+ "invoke_agent",
587
+ "invoke_workflow",
588
+ "create_agent",
589
+ "embeddings",
590
+ }
591
+ if head in known:
592
+ return head
593
+ if name in known:
594
+ return name
595
+ return None
596
+
597
+
598
+ def _name_tail(name: Any, default: str) -> str:
599
+ if not isinstance(name, str) or not name:
600
+ return default
601
+ parts = name.split(None, 1)
602
+ if len(parts) == 2:
603
+ return parts[1]
604
+ return name
605
+
606
+
607
+ def _as_int(value: Any) -> int | None:
608
+ if isinstance(value, bool) or value is None:
609
+ return None
610
+ if isinstance(value, int):
611
+ return value
612
+ if isinstance(value, float) and value.is_integer():
613
+ return int(value)
614
+ if isinstance(value, str) and value.lstrip("-").isdigit():
615
+ return int(value)
616
+ return None
617
+
618
+
619
+ def _sum_int(values: Any) -> int | None:
620
+ total = 0
621
+ found = False
622
+ for value in values:
623
+ number = _as_int(value)
624
+ if number is None:
625
+ continue
626
+ total += number
627
+ found = True
628
+ return total if found else None