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.
- agent_reliability_harness/__init__.py +58 -0
- agent_reliability_harness/__main__.py +4 -0
- agent_reliability_harness/adapters/__init__.py +103 -0
- agent_reliability_harness/adapters/anthropic_messages.py +158 -0
- agent_reliability_harness/adapters/openai_chat.py +155 -0
- agent_reliability_harness/cli.py +388 -0
- agent_reliability_harness/models.py +649 -0
- agent_reliability_harness/py.typed +0 -0
- agent_reliability_harness/regression.py +551 -0
- agent_reliability_harness/report.py +301 -0
- agent_reliability_harness/rules.py +121 -0
- agent_reliability_harness/validator.py +1121 -0
- agent_reliability_harness-0.2.1.dist-info/METADATA +229 -0
- agent_reliability_harness-0.2.1.dist-info/RECORD +18 -0
- agent_reliability_harness-0.2.1.dist-info/WHEEL +5 -0
- agent_reliability_harness-0.2.1.dist-info/entry_points.txt +2 -0
- agent_reliability_harness-0.2.1.dist-info/licenses/LICENSE +21 -0
- agent_reliability_harness-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -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,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
|