agent-reliability-harness 0.2.1__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.
@@ -0,0 +1,58 @@
1
+ """agent_reliability_harness
2
+
3
+ A dependency-free, local-first reliability and regression-testing harness
4
+ for tool-using AI agents. It validates agent execution traces against
5
+ declarative policies (tool-call contracts, trajectory rules, budgets,
6
+ unsafe-pattern detection, grounding), and compares candidate runs against a
7
+ saved baseline to gate CI on real regressions.
8
+
9
+ The core is deterministic: no model calls, no network, no clock reads.
10
+ """
11
+
12
+ from agent_reliability_harness.models import (
13
+ SCHEMA_VERSION,
14
+ ArgSpec,
15
+ Budgets,
16
+ CompletionPolicy,
17
+ ErrorHandlingPolicy,
18
+ Finding,
19
+ GroundingPolicy,
20
+ Policy,
21
+ SequencePolicy,
22
+ Step,
23
+ ToolSchema,
24
+ Trace,
25
+ TraceReport,
26
+ )
27
+ from agent_reliability_harness.regression import (
28
+ ComparisonResult,
29
+ compare_reports,
30
+ evaluate_gate,
31
+ )
32
+ from agent_reliability_harness.rules import RULES, Rule
33
+ from agent_reliability_harness.validator import validate_trace
34
+
35
+ __version__ = "0.2.1"
36
+
37
+ __all__ = [
38
+ "ArgSpec",
39
+ "Budgets",
40
+ "CompletionPolicy",
41
+ "ComparisonResult",
42
+ "ErrorHandlingPolicy",
43
+ "Finding",
44
+ "GroundingPolicy",
45
+ "Policy",
46
+ "RULES",
47
+ "Rule",
48
+ "SCHEMA_VERSION",
49
+ "SequencePolicy",
50
+ "Step",
51
+ "ToolSchema",
52
+ "Trace",
53
+ "TraceReport",
54
+ "compare_reports",
55
+ "evaluate_gate",
56
+ "validate_trace",
57
+ "__version__",
58
+ ]
@@ -0,0 +1,4 @@
1
+ from agent_reliability_harness.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,103 @@
1
+ """Provider adapters: convert provider-native transcripts into canonical traces.
2
+
3
+ Adapters are pure, deterministic functions from a provider-native JSON
4
+ document to a canonical ARH trace ``dict`` (see TRACE-SPEC.md). They never
5
+ call a network, never read a clock, and never drop information silently:
6
+ anything an adapter cannot map is recorded under
7
+ ``trace["metadata"]["adapter"]`` so the loss is visible.
8
+
9
+ Supported formats:
10
+
11
+ - ``arh``: the canonical trace format (no conversion).
12
+ - ``openai-chat``: an OpenAI Chat Completions message list with
13
+ ``tool_calls`` / ``role: "tool"`` messages.
14
+ - ``anthropic-messages``: an Anthropic Messages API conversation with
15
+ ``tool_use`` / ``tool_result`` content blocks.
16
+
17
+ Fields the source format does not carry (see docs/adapters.md for the full
18
+ support matrix) are left unset, which automatically marks the dependent
19
+ policy checks (e.g. latency budgets) as not applicable instead of silently
20
+ passing them.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ from agent_reliability_harness.adapters import anthropic_messages, openai_chat
29
+
30
+ FORMAT_ARH = "arh"
31
+ FORMAT_OPENAI_CHAT = "openai-chat"
32
+ FORMAT_ANTHROPIC_MESSAGES = "anthropic-messages"
33
+ FORMAT_AUTO = "auto"
34
+
35
+ FORMATS = (FORMAT_ARH, FORMAT_OPENAI_CHAT, FORMAT_ANTHROPIC_MESSAGES)
36
+
37
+ _ADAPTERS: dict[str, Callable[[Any, str], dict[str, Any]]] = {
38
+ FORMAT_OPENAI_CHAT: openai_chat.to_trace_dict,
39
+ FORMAT_ANTHROPIC_MESSAGES: anthropic_messages.to_trace_dict,
40
+ }
41
+
42
+
43
+ def detect_format(raw: Any) -> str:
44
+ """Deterministically detect the trace format of a parsed JSON document.
45
+
46
+ Detection rules, applied in order:
47
+
48
+ 1. An object with a ``steps`` list is the canonical ``arh`` format.
49
+ 2. An object with a ``messages`` list (or a bare message list) is
50
+ inspected message by message:
51
+ a. any ``role: "assistant"`` message with ``tool_calls`` or
52
+ ``function_call``, or any ``role: "tool"`` message -> ``openai-chat``;
53
+ b. any message whose ``content`` is a list containing ``tool_use`` /
54
+ ``tool_result`` typed blocks -> ``anthropic-messages``.
55
+ 3. A plain-text-only message list defaults to ``openai-chat`` (both
56
+ vendors' text-only transcripts are structurally identical; the
57
+ resulting canonical trace is the same either way).
58
+
59
+ Raises ``ValueError`` for anything else.
60
+ """
61
+ if isinstance(raw, dict) and isinstance(raw.get("steps"), list):
62
+ return FORMAT_ARH
63
+ messages: Any = None
64
+ if isinstance(raw, dict) and isinstance(raw.get("messages"), list):
65
+ messages = raw["messages"]
66
+ elif isinstance(raw, list):
67
+ messages = raw
68
+ if messages is None:
69
+ raise ValueError(
70
+ "cannot detect trace format: expected an object with 'steps' (arh), "
71
+ "an object with 'messages', or a bare message list"
72
+ )
73
+ for message in messages:
74
+ if not isinstance(message, dict):
75
+ continue
76
+ if message.get("role") == "tool" or "tool_calls" in message or "function_call" in message:
77
+ return FORMAT_OPENAI_CHAT
78
+ content = message.get("content")
79
+ if isinstance(content, list) and any(
80
+ isinstance(block, dict) and block.get("type") in ("tool_use", "tool_result")
81
+ for block in content
82
+ ):
83
+ return FORMAT_ANTHROPIC_MESSAGES
84
+ return FORMAT_OPENAI_CHAT
85
+
86
+
87
+ def normalize(raw: Any, fmt: str, fallback_trace_id: str) -> dict[str, Any]:
88
+ """Convert a provider-native document into a canonical trace dict.
89
+
90
+ ``fmt`` may be a concrete format name or ``"auto"`` for detection.
91
+ ``fallback_trace_id`` is used when the source document carries no trace
92
+ identifier (typically the source file stem).
93
+ """
94
+ if fmt == FORMAT_AUTO:
95
+ fmt = detect_format(raw)
96
+ if fmt == FORMAT_ARH:
97
+ if not isinstance(raw, dict):
98
+ raise ValueError("arh-format trace must be a JSON object")
99
+ return raw
100
+ adapter = _ADAPTERS.get(fmt)
101
+ if adapter is None:
102
+ raise ValueError(f"unknown trace format '{fmt}' (expected one of {FORMATS})")
103
+ return adapter(raw, fallback_trace_id)
@@ -0,0 +1,158 @@
1
+ """Adapter for Anthropic Messages API conversations.
2
+
3
+ Maps an Anthropic-style conversation (list of messages whose ``content`` is
4
+ a string or a list of typed blocks) into a canonical ARH trace:
5
+
6
+ - assistant ``tool_use`` blocks become ``tool_call`` steps (``input`` maps
7
+ to ``arguments``);
8
+ - user ``tool_result`` blocks attach their content as the ``output`` of the
9
+ matching tool_call step (via ``tool_use_id``); ``is_error: true`` sets the
10
+ step's ``status`` to ``"error"`` and records the result text as ``error``;
11
+ - assistant ``text`` blocks become ``model_response`` steps; a block's
12
+ ``citations`` list (Anthropic citations feature) maps to step citations;
13
+ - the top-level ``system`` prompt and user text content are skipped: they
14
+ are inputs to the agent, not agent behavior.
15
+
16
+ Not carried by this format (documented, not guessed): latency, cost, and
17
+ per-message token usage (transcripts do not embed ``usage``). Orphan tool
18
+ results are recorded under ``metadata.adapter`` instead of being dropped.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ SOURCE = "anthropic-messages"
26
+
27
+
28
+ def _result_text(content: Any) -> Any:
29
+ """Flatten a tool_result content payload to a comparable value."""
30
+ if isinstance(content, list):
31
+ texts = [
32
+ block.get("text", "")
33
+ for block in content
34
+ if isinstance(block, dict) and block.get("type") == "text"
35
+ ]
36
+ if texts:
37
+ return "\n".join(texts)
38
+ return content
39
+
40
+
41
+ def to_trace_dict(raw: Any, fallback_trace_id: str) -> dict[str, Any]:
42
+ if isinstance(raw, list):
43
+ wrapper: dict[str, Any] = {"messages": raw}
44
+ elif isinstance(raw, dict):
45
+ wrapper = raw
46
+ else:
47
+ raise ValueError(
48
+ f"anthropic-messages input must be an object with 'messages' or a message "
49
+ f"list, got {type(raw).__name__}"
50
+ )
51
+ messages = wrapper.get("messages")
52
+ if not isinstance(messages, list):
53
+ raise ValueError("anthropic-messages input has no 'messages' list")
54
+
55
+ steps: list[dict[str, Any]] = []
56
+ step_by_use_id: dict[str, dict[str, Any]] = {}
57
+ adapter_notes: list[dict[str, Any]] = []
58
+
59
+ for index, message in enumerate(messages):
60
+ if not isinstance(message, dict):
61
+ adapter_notes.append({"issue": "non_object_message", "message_index": index})
62
+ continue
63
+ role = message.get("role")
64
+ content = message.get("content")
65
+ if role == "assistant":
66
+ if isinstance(content, str):
67
+ if content.strip():
68
+ steps.append(
69
+ {
70
+ "step_id": f"m{index}-text0",
71
+ "type": "model_response",
72
+ "text": content,
73
+ }
74
+ )
75
+ continue
76
+ if not isinstance(content, list):
77
+ continue
78
+ for block_index, block in enumerate(content):
79
+ if not isinstance(block, dict):
80
+ adapter_notes.append(
81
+ {
82
+ "issue": "non_object_block",
83
+ "message_index": index,
84
+ "block_index": block_index,
85
+ }
86
+ )
87
+ continue
88
+ block_type = block.get("type")
89
+ if block_type == "tool_use":
90
+ step_id = str(block.get("id") or f"m{index}-tool{block_index}")
91
+ arguments = block.get("input")
92
+ step: dict[str, Any] = {
93
+ "step_id": step_id,
94
+ "type": "tool_call",
95
+ "tool_name": block.get("name"),
96
+ "arguments": arguments if isinstance(arguments, dict) else {},
97
+ }
98
+ if not isinstance(arguments, dict) and arguments is not None:
99
+ adapter_notes.append(
100
+ {
101
+ "step_id": step_id,
102
+ "issue": "arguments_not_object",
103
+ "raw_input": arguments,
104
+ }
105
+ )
106
+ steps.append(step)
107
+ step_by_use_id[step_id] = step
108
+ elif block_type == "text":
109
+ text = block.get("text")
110
+ if isinstance(text, str) and text.strip():
111
+ step = {
112
+ "step_id": f"m{index}-text{block_index}",
113
+ "type": "model_response",
114
+ "text": text,
115
+ }
116
+ citations = block.get("citations")
117
+ if isinstance(citations, list) and citations:
118
+ step["citations"] = [
119
+ c for c in citations if isinstance(c, dict)
120
+ ]
121
+ steps.append(step)
122
+ elif role == "user":
123
+ if not isinstance(content, list):
124
+ continue # plain user text: agent input, skipped
125
+ for block in content:
126
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
127
+ continue
128
+ use_id = block.get("tool_use_id")
129
+ target = step_by_use_id.get(str(use_id)) if use_id is not None else None
130
+ if target is None:
131
+ adapter_notes.append(
132
+ {
133
+ "issue": "unmatched_tool_result",
134
+ "message_index": index,
135
+ "tool_use_id": use_id,
136
+ }
137
+ )
138
+ continue
139
+ result = _result_text(block.get("content"))
140
+ target["output"] = result
141
+ if block.get("is_error") is True:
142
+ target["status"] = "error"
143
+ target["error"] = result if isinstance(result, str) else "tool error"
144
+
145
+ trace: dict[str, Any] = {
146
+ "schema_version": "1",
147
+ "trace_id": str(wrapper.get("trace_id") or fallback_trace_id),
148
+ "agent_name": str(wrapper.get("agent_name") or "anthropic-agent"),
149
+ "workflow": str(wrapper.get("workflow") or "anthropic-messages-import"),
150
+ "source": SOURCE,
151
+ "steps": steps,
152
+ }
153
+ metadata = dict(wrapper.get("metadata") or {})
154
+ if adapter_notes:
155
+ metadata.setdefault("adapter", {})["notes"] = adapter_notes
156
+ if metadata:
157
+ trace["metadata"] = metadata
158
+ return trace
@@ -0,0 +1,155 @@
1
+ """Adapter for OpenAI Chat Completions message lists.
2
+
3
+ Maps an OpenAI-style conversation transcript into a canonical ARH trace:
4
+
5
+ - each entry of an assistant message's ``tool_calls`` becomes a
6
+ ``tool_call`` step (arguments JSON-decoded from ``function.arguments``);
7
+ - legacy single ``function_call`` fields are mapped the same way;
8
+ - ``role: "tool"`` messages attach their ``content`` as the ``output`` of
9
+ the matching tool_call step (via ``tool_call_id``);
10
+ - non-empty assistant text ``content`` becomes a ``model_response`` step;
11
+ - ``system`` and ``user`` messages are skipped: they are inputs to the
12
+ agent, not agent behavior. (Policy safety checks scan agent-produced
13
+ content; if you need to scan user input, include it in the canonical
14
+ format directly.)
15
+
16
+ Not carried by this format (documented, not guessed): latency, cost, token
17
+ usage per message, citations, and step status (the Chat Completions
18
+ transcript has no error channel for tool results, so ``status`` is always
19
+ ``"ok"``). Unparseable ``function.arguments`` and orphan tool results are
20
+ recorded under ``metadata.adapter`` instead of being dropped.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from typing import Any
27
+
28
+ SOURCE = "openai-chat"
29
+
30
+
31
+ def _tool_call_step(
32
+ call: dict[str, Any], step_id: str, adapter_notes: list[dict[str, Any]]
33
+ ) -> dict[str, Any]:
34
+ function = call.get("function") or {}
35
+ name = function.get("name") or call.get("name")
36
+ raw_arguments = function.get("arguments", call.get("arguments"))
37
+ arguments: dict[str, Any] = {}
38
+ if isinstance(raw_arguments, dict):
39
+ arguments = raw_arguments
40
+ elif isinstance(raw_arguments, str) and raw_arguments.strip():
41
+ try:
42
+ parsed = json.loads(raw_arguments)
43
+ if isinstance(parsed, dict):
44
+ arguments = parsed
45
+ else:
46
+ adapter_notes.append(
47
+ {
48
+ "step_id": step_id,
49
+ "issue": "arguments_not_object",
50
+ "raw_arguments": raw_arguments,
51
+ }
52
+ )
53
+ except json.JSONDecodeError as exc:
54
+ adapter_notes.append(
55
+ {
56
+ "step_id": step_id,
57
+ "issue": "argument_parse_error",
58
+ "error": str(exc),
59
+ "raw_arguments": raw_arguments,
60
+ }
61
+ )
62
+ step: dict[str, Any] = {
63
+ "step_id": step_id,
64
+ "type": "tool_call",
65
+ "tool_name": name,
66
+ "arguments": arguments,
67
+ }
68
+ return step
69
+
70
+
71
+ def to_trace_dict(raw: Any, fallback_trace_id: str) -> dict[str, Any]:
72
+ if isinstance(raw, list):
73
+ wrapper: dict[str, Any] = {"messages": raw}
74
+ elif isinstance(raw, dict):
75
+ wrapper = raw
76
+ else:
77
+ raise ValueError(
78
+ f"openai-chat input must be an object with 'messages' or a message list, "
79
+ f"got {type(raw).__name__}"
80
+ )
81
+ messages = wrapper.get("messages")
82
+ if not isinstance(messages, list):
83
+ raise ValueError("openai-chat input has no 'messages' list")
84
+
85
+ steps: list[dict[str, Any]] = []
86
+ step_by_call_id: dict[str, dict[str, Any]] = {}
87
+ adapter_notes: list[dict[str, Any]] = []
88
+
89
+ for index, message in enumerate(messages):
90
+ if not isinstance(message, dict):
91
+ adapter_notes.append({"issue": "non_object_message", "message_index": index})
92
+ continue
93
+ role = message.get("role")
94
+ if role == "assistant":
95
+ tool_calls = message.get("tool_calls")
96
+ if isinstance(tool_calls, list):
97
+ for call_index, call in enumerate(tool_calls):
98
+ if not isinstance(call, dict):
99
+ adapter_notes.append(
100
+ {
101
+ "issue": "non_object_tool_call",
102
+ "message_index": index,
103
+ "tool_call_index": call_index,
104
+ }
105
+ )
106
+ continue
107
+ step_id = str(call.get("id") or f"m{index}-tool{call_index}")
108
+ step = _tool_call_step(call, step_id, adapter_notes)
109
+ steps.append(step)
110
+ step_by_call_id[step_id] = step
111
+ function_call = message.get("function_call")
112
+ if isinstance(function_call, dict):
113
+ step_id = f"m{index}-function"
114
+ step = _tool_call_step(function_call, step_id, adapter_notes)
115
+ steps.append(step)
116
+ step_by_call_id[step_id] = step
117
+ content = message.get("content")
118
+ if isinstance(content, str) and content.strip():
119
+ steps.append(
120
+ {
121
+ "step_id": f"m{index}-response",
122
+ "type": "model_response",
123
+ "text": content,
124
+ }
125
+ )
126
+ elif role == "tool":
127
+ call_id = message.get("tool_call_id")
128
+ target = step_by_call_id.get(str(call_id)) if call_id is not None else None
129
+ content = message.get("content")
130
+ if target is None:
131
+ adapter_notes.append(
132
+ {
133
+ "issue": "unmatched_tool_result",
134
+ "message_index": index,
135
+ "tool_call_id": call_id,
136
+ }
137
+ )
138
+ else:
139
+ target["output"] = content
140
+ # system/user/developer messages: agent inputs, intentionally skipped.
141
+
142
+ trace: dict[str, Any] = {
143
+ "schema_version": "1",
144
+ "trace_id": str(wrapper.get("trace_id") or fallback_trace_id),
145
+ "agent_name": str(wrapper.get("agent_name") or "openai-chat-agent"),
146
+ "workflow": str(wrapper.get("workflow") or "openai-chat-import"),
147
+ "source": SOURCE,
148
+ "steps": steps,
149
+ }
150
+ metadata = dict(wrapper.get("metadata") or {})
151
+ if adapter_notes:
152
+ metadata.setdefault("adapter", {})["notes"] = adapter_notes
153
+ if metadata:
154
+ trace["metadata"] = metadata
155
+ return trace