agentguard-eval 0.3.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.
- agentguard/__init__.py +3 -0
- agentguard/cli.py +101 -0
- agentguard/gate/__init__.py +50 -0
- agentguard/gate/governor.py +256 -0
- agentguard/gate/ledger.py +143 -0
- agentguard/gate/policy.py +215 -0
- agentguard/provenance/__init__.py +46 -0
- agentguard/provenance/adapters.py +142 -0
- agentguard/provenance/check.py +301 -0
- agentguard/provenance/failure.py +100 -0
- agentguard/provenance/ids.py +25 -0
- agentguard/provenance/types.py +85 -0
- agentguard/provenance/values.py +233 -0
- agentguard/py.typed +0 -0
- agentguard/pytest_plugin.py +85 -0
- agentguard/shell/__init__.py +37 -0
- agentguard/shell/model.py +308 -0
- agentguard_eval-0.3.0.dist-info/METADATA +184 -0
- agentguard_eval-0.3.0.dist-info/RECORD +22 -0
- agentguard_eval-0.3.0.dist-info/WHEEL +4 -0
- agentguard_eval-0.3.0.dist-info/entry_points.txt +5 -0
- agentguard_eval-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""A deterministic policy gate for agent tool calls.
|
|
2
|
+
|
|
3
|
+
Every tool call is a *proposal*. ``evaluate`` returns one of three verdicts: run it, hold it
|
|
4
|
+
for a person, or refuse it. The gate is a pure function of the proposal, the budget, and a
|
|
5
|
+
small history object, so the same inputs always give the same verdict, which is what makes it
|
|
6
|
+
testable. Ported from the TypeScript ``agent-governance`` project; the reason strings match it
|
|
7
|
+
word for word, because they end up in ledger entries whose hashes must agree across the two.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"POLICY",
|
|
20
|
+
"UNATTENDED_WINDOW_SECONDS",
|
|
21
|
+
"UNATTENDED_WRITE_LIMIT",
|
|
22
|
+
"Budget",
|
|
23
|
+
"Decision",
|
|
24
|
+
"History",
|
|
25
|
+
"Verdict",
|
|
26
|
+
"evaluate",
|
|
27
|
+
"money",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Verdict(str, Enum):
|
|
32
|
+
"""What the gate decided."""
|
|
33
|
+
|
|
34
|
+
ALLOW = "ALLOW"
|
|
35
|
+
REQUIRE_APPROVAL = "REQUIRE_APPROVAL"
|
|
36
|
+
DENY = "DENY"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class _Rule:
|
|
41
|
+
id: str
|
|
42
|
+
describe: str
|
|
43
|
+
tier: int
|
|
44
|
+
match: tuple[str, ...]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Policy is data, not conditionals buried in a service. Each rule is a row you can read and
|
|
48
|
+
# reorder. Tier 1 reads run unattended, tier 2 internal writes run but are recorded, tier 3
|
|
49
|
+
# (money, customers, production, deletes) waits for a human.
|
|
50
|
+
POLICY: tuple[_Rule, ...] = (
|
|
51
|
+
_Rule("read-only", "Read-only lookups cannot change anything, so they run unattended.", 1,
|
|
52
|
+
("crm.read", "db.query", "search.*", "calendar.read")),
|
|
53
|
+
_Rule("internal-write", "Writes to internal records are reversible and stay inside the company.", 2,
|
|
54
|
+
("crm.update", "ticket.create", "notify.internal", "db.insert")),
|
|
55
|
+
_Rule("money-movement", "Anything that moves money needs a person to sign off.", 3,
|
|
56
|
+
("stripe.*", "payout.*", "invoice.void", "refund.*")),
|
|
57
|
+
_Rule("customer-contact", "A message to a customer cannot be unsent.", 3,
|
|
58
|
+
("email.send", "sms.send", "whatsapp.send")),
|
|
59
|
+
_Rule("destructive", "Deletes and production deploys need a person to sign off.", 3,
|
|
60
|
+
("db.delete", "db.drop", "deploy.production", "s3.delete")),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Lowercase ASCII segments joined by dots, at least two of them: ``stripe.refund``. Matched
|
|
64
|
+
# with ``fullmatch``: Python's ``$`` also matches just before a trailing newline, so a name like
|
|
65
|
+
# ``"refund.create\n"`` would otherwise smuggle itself past this check.
|
|
66
|
+
_TOOL_NAME = re.compile(r"[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+")
|
|
67
|
+
|
|
68
|
+
# A single write is routine; a loop creating hundreds of tickets is a runaway agent. Past this
|
|
69
|
+
# many unattended tier-2 writes from one agent inside the window, the next one waits for a person.
|
|
70
|
+
UNATTENDED_WRITE_LIMIT = 10
|
|
71
|
+
UNATTENDED_WINDOW_SECONDS = 60
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Budget:
|
|
76
|
+
"""A spend cap, in cents. ``spent_cents`` is what has been charged so far."""
|
|
77
|
+
|
|
78
|
+
spent_cents: int
|
|
79
|
+
limit_cents: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class History:
|
|
84
|
+
"""What the gate knows about recent activity. The caller supplies it; the gate stays pure."""
|
|
85
|
+
|
|
86
|
+
recent_unattended_writes: int = 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Decision:
|
|
91
|
+
"""The gate's answer, with every reason it is safe to show a human reviewer."""
|
|
92
|
+
|
|
93
|
+
verdict: Verdict
|
|
94
|
+
tier: int
|
|
95
|
+
reasons: tuple[str, ...]
|
|
96
|
+
decided_by: str
|
|
97
|
+
#: What the gate charged against the budget: the larger of the estimate and any amount the
|
|
98
|
+
#: arguments move. 0 when the gate refused before it could trust either number.
|
|
99
|
+
cost_cents: int
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def reason(self) -> str:
|
|
103
|
+
"""The final reason, i.e. the one that produced the verdict."""
|
|
104
|
+
return self.reasons[-1] if self.reasons else ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def money(cents: int) -> str:
|
|
108
|
+
"""Render cents as a dollar string: ``money(8400)`` -> ``"$84.00"``."""
|
|
109
|
+
return f"${cents / 100:.2f}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_cents(value: object) -> bool:
|
|
113
|
+
"""A whole, non-negative amount the budget arithmetic can trust (and not a bool)."""
|
|
114
|
+
return (
|
|
115
|
+
isinstance(value, int)
|
|
116
|
+
and not isinstance(value, bool)
|
|
117
|
+
and value >= 0
|
|
118
|
+
) or (isinstance(value, float) and value.is_integer() and value >= 0 and math.isfinite(value))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _matches(pattern: str, tool: str) -> bool:
|
|
122
|
+
if pattern.endswith("*"):
|
|
123
|
+
return tool.startswith(pattern[:-1])
|
|
124
|
+
return pattern == tool
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _rule_for(tool: str) -> _Rule | None:
|
|
128
|
+
for rule in POLICY:
|
|
129
|
+
if any(_matches(pattern, tool) for pattern in rule.match):
|
|
130
|
+
return rule
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def evaluate(
|
|
135
|
+
tool: str,
|
|
136
|
+
*,
|
|
137
|
+
estimated_cost_cents: int = 0,
|
|
138
|
+
args: dict[str, object] | None = None,
|
|
139
|
+
budget: Budget,
|
|
140
|
+
history: History | None = None,
|
|
141
|
+
) -> Decision:
|
|
142
|
+
"""Decide whether a proposed tool call may run, in seven ordered checks.
|
|
143
|
+
|
|
144
|
+
The order is load-bearing and asserted by the tests: a malformed tool name is refused
|
|
145
|
+
before it can borrow another rule's tier, the budget is checked before the tier, and an
|
|
146
|
+
understated cost is escalated even on a tier that would otherwise run unattended.
|
|
147
|
+
"""
|
|
148
|
+
args = args or {}
|
|
149
|
+
history = history or History()
|
|
150
|
+
reasons: list[str] = []
|
|
151
|
+
|
|
152
|
+
# 0. A tool name has to look like a tool name before it is matched. Wildcards match by
|
|
153
|
+
# prefix, so without this a name like "search.x/../refund.create" would ride "search.*".
|
|
154
|
+
if not _TOOL_NAME.fullmatch(tool):
|
|
155
|
+
return Decision(Verdict.DENY, 3, (f"{json.dumps(tool, ensure_ascii=False)} is not a well-formed tool name.",), "malformed-tool", 0)
|
|
156
|
+
|
|
157
|
+
# 1. Unknown tools fail closed.
|
|
158
|
+
rule = _rule_for(tool)
|
|
159
|
+
if rule is None:
|
|
160
|
+
return Decision(Verdict.DENY, 3, (f'"{tool}" is not a tool this agent is allowed to call.',), "unknown-tool", 0)
|
|
161
|
+
reasons.append(rule.describe)
|
|
162
|
+
|
|
163
|
+
# 2. Both numbers must be ones the math can trust: the estimate and any amount the
|
|
164
|
+
# arguments move. A negative or NaN cost would slip past the cap.
|
|
165
|
+
if not _is_cents(estimated_cost_cents):
|
|
166
|
+
reasons.append(f"An estimated cost of {estimated_cost_cents} cents is not a whole, non-negative amount.")
|
|
167
|
+
return Decision(Verdict.DENY, rule.tier, tuple(reasons), "malformed-cost", 0)
|
|
168
|
+
arg_amount = args.get("amount_cents")
|
|
169
|
+
if arg_amount is not None and not _is_cents(arg_amount):
|
|
170
|
+
reasons.append(f"The arguments ask to move {arg_amount} cents, which is not a whole, non-negative amount.")
|
|
171
|
+
return Decision(Verdict.DENY, rule.tier, tuple(reasons), "malformed-cost", 0)
|
|
172
|
+
|
|
173
|
+
# The estimate is the agent's word; amount_cents is what the call will do. Charge the
|
|
174
|
+
# larger, so understating the estimate can't buy room under the cap.
|
|
175
|
+
estimate = int(estimated_cost_cents)
|
|
176
|
+
moves = int(arg_amount) if isinstance(arg_amount, (int, float)) and _is_cents(arg_amount) else 0
|
|
177
|
+
cost = max(estimate, moves)
|
|
178
|
+
understated = cost > estimate
|
|
179
|
+
|
|
180
|
+
# 3. Budget is checked before anything else, because a runaway spend is the failure that
|
|
181
|
+
# costs real money while everyone is asleep.
|
|
182
|
+
projected = budget.spent_cents + cost
|
|
183
|
+
if projected > budget.limit_cents:
|
|
184
|
+
reasons.append(f"This would put spend at {money(projected)} against a {money(budget.limit_cents)} cap.")
|
|
185
|
+
return Decision(Verdict.DENY, rule.tier, tuple(reasons), "budget-exceeded", cost)
|
|
186
|
+
|
|
187
|
+
# 4. An agent that estimates one number and moves a bigger one gets a human's eyes, even on
|
|
188
|
+
# a tier that would normally run unattended, and the human sees the real amount.
|
|
189
|
+
if understated:
|
|
190
|
+
reasons.append(f"The agent estimated {money(estimate)}, but the arguments move {money(cost)} — escalated for review.")
|
|
191
|
+
return Decision(Verdict.REQUIRE_APPROVAL, 3, tuple(reasons), "cost-understated", cost)
|
|
192
|
+
|
|
193
|
+
# 5. One action asking for more than a quarter of the whole budget is worth a second look
|
|
194
|
+
# even when it fits.
|
|
195
|
+
if cost > budget.limit_cents / 4:
|
|
196
|
+
reasons.append(f"{money(cost)} in one action is unusually large — escalated for review.")
|
|
197
|
+
return Decision(Verdict.REQUIRE_APPROVAL, 3, tuple(reasons), "large-single-spend", cost)
|
|
198
|
+
|
|
199
|
+
if rule.tier == 3:
|
|
200
|
+
reasons.append("Tier 3 always waits for a human.")
|
|
201
|
+
return Decision(Verdict.REQUIRE_APPROVAL, 3, tuple(reasons), rule.id, cost)
|
|
202
|
+
|
|
203
|
+
if rule.tier == 2:
|
|
204
|
+
# A runaway loop of individually harmless writes is still a runaway. Reads are never slowed.
|
|
205
|
+
recent = history.recent_unattended_writes
|
|
206
|
+
if recent >= UNATTENDED_WRITE_LIMIT:
|
|
207
|
+
reasons.append(
|
|
208
|
+
f"This agent has already run {recent} unattended writes in the last "
|
|
209
|
+
f"{UNATTENDED_WINDOW_SECONDS}s (limit {UNATTENDED_WRITE_LIMIT}), so the next one waits for a person."
|
|
210
|
+
)
|
|
211
|
+
return Decision(Verdict.REQUIRE_APPROVAL, 2, tuple(reasons), "unattended-rate", cost)
|
|
212
|
+
reasons.append("Tier 2 runs unattended but is recorded in full.")
|
|
213
|
+
return Decision(Verdict.ALLOW, 2, tuple(reasons), rule.id, cost)
|
|
214
|
+
|
|
215
|
+
return Decision(Verdict.ALLOW, 1, tuple(reasons), rule.id, cost)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Provenance checking: is an agent's answer backed by what its tools returned?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentguard.provenance.adapters import from_anthropic, from_openai, strip_tool_markup, to_transcript
|
|
6
|
+
from agentguard.provenance.check import check_transcript, check_turns
|
|
7
|
+
from agentguard.provenance.failure import acknowledges_failure, classify_result
|
|
8
|
+
from agentguard.provenance.ids import id_flags
|
|
9
|
+
from agentguard.provenance.types import (
|
|
10
|
+
Flag,
|
|
11
|
+
FlagKind,
|
|
12
|
+
Step,
|
|
13
|
+
Transcript,
|
|
14
|
+
TurnVerdict,
|
|
15
|
+
Verdict,
|
|
16
|
+
load_transcript,
|
|
17
|
+
)
|
|
18
|
+
from agentguard.provenance.values import (
|
|
19
|
+
extract_values,
|
|
20
|
+
is_id_shaped,
|
|
21
|
+
normalize_number,
|
|
22
|
+
payload_values,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Flag",
|
|
27
|
+
"FlagKind",
|
|
28
|
+
"Step",
|
|
29
|
+
"Transcript",
|
|
30
|
+
"TurnVerdict",
|
|
31
|
+
"Verdict",
|
|
32
|
+
"acknowledges_failure",
|
|
33
|
+
"check_transcript",
|
|
34
|
+
"check_turns",
|
|
35
|
+
"classify_result",
|
|
36
|
+
"extract_values",
|
|
37
|
+
"from_anthropic",
|
|
38
|
+
"from_openai",
|
|
39
|
+
"id_flags",
|
|
40
|
+
"is_id_shaped",
|
|
41
|
+
"load_transcript",
|
|
42
|
+
"normalize_number",
|
|
43
|
+
"payload_values",
|
|
44
|
+
"strip_tool_markup",
|
|
45
|
+
"to_transcript",
|
|
46
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Read real agent logs: OpenAI chat messages and Anthropic messages.
|
|
2
|
+
|
|
3
|
+
Maps either format onto the checker's transcript shape, so you can point it at what your agent
|
|
4
|
+
actually produced. Ported from tool-truth's ``adapters.ts``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agentguard.provenance.types import Step, Transcript, load_transcript
|
|
14
|
+
|
|
15
|
+
__all__ = ["from_anthropic", "from_openai", "strip_tool_markup", "to_transcript"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_payload(content: Any) -> Any:
|
|
19
|
+
"""Tool outputs usually arrive as strings; JSON is parsed so its fields can be checked."""
|
|
20
|
+
if not isinstance(content, str):
|
|
21
|
+
return content
|
|
22
|
+
s = content.strip()
|
|
23
|
+
if not (s.startswith("{") or s.startswith("[")):
|
|
24
|
+
return content
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(s)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return content
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_args(raw: Any) -> dict[str, Any]:
|
|
32
|
+
if isinstance(raw, dict):
|
|
33
|
+
return raw
|
|
34
|
+
if isinstance(raw, str):
|
|
35
|
+
try:
|
|
36
|
+
value = json.loads(raw)
|
|
37
|
+
except ValueError:
|
|
38
|
+
return {}
|
|
39
|
+
return value if isinstance(value, dict) else {}
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tag(name: str) -> re.Pattern[str]:
|
|
44
|
+
# Tags may carry a namespace prefix (``<ns:invoke ...>``), so it's matched optionally.
|
|
45
|
+
return re.compile(rf"<(?:[\w-]+:)?{name}\b[\s\S]*?(</(?:[\w-]+:)?{name}>|$)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_MARKUP = [_tag("function_calls"), _tag("invoke"), _tag("tool_call")]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def strip_tool_markup(text: str) -> str:
|
|
52
|
+
"""Remove tool calls a model also wrote into its message text, e.g. Claude's
|
|
53
|
+
``<function_calls><invoke name=...>``. That's an action, not a statement to the user, and the
|
|
54
|
+
structured call is already captured, so tool names aren't read as invented claims."""
|
|
55
|
+
for pattern in _MARKUP:
|
|
56
|
+
text = pattern.sub(" ", text)
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def from_openai(messages: list[dict[str, Any]]) -> Transcript:
|
|
61
|
+
"""OpenAI chat-completions messages (``tool_calls`` and role ``tool``)."""
|
|
62
|
+
steps: list[Step] = []
|
|
63
|
+
tool_names: dict[str, str] = {}
|
|
64
|
+
for m in messages:
|
|
65
|
+
role = m.get("role")
|
|
66
|
+
content = m.get("content")
|
|
67
|
+
if role in ("system", "developer") and isinstance(content, str):
|
|
68
|
+
steps.append(Step(role="system", content=content))
|
|
69
|
+
elif role == "user" and isinstance(content, str):
|
|
70
|
+
steps.append(Step(role="user", content=content))
|
|
71
|
+
elif role == "assistant":
|
|
72
|
+
for call in m.get("tool_calls") or []:
|
|
73
|
+
fn = call.get("function") or {}
|
|
74
|
+
cid = str(call.get("id", ""))
|
|
75
|
+
tool = str(fn.get("name", "unknown"))
|
|
76
|
+
tool_names[cid] = tool
|
|
77
|
+
steps.append(Step(role="tool_call", id=cid, tool=tool, args=_parse_args(fn.get("arguments"))))
|
|
78
|
+
said = strip_tool_markup(content) if isinstance(content, str) else ""
|
|
79
|
+
if said.strip():
|
|
80
|
+
steps.append(Step(role="assistant", content=said))
|
|
81
|
+
elif role == "tool":
|
|
82
|
+
cid = str(m.get("tool_call_id", ""))
|
|
83
|
+
steps.append(Step(role="tool_result", id=cid, tool=tool_names.get(cid, "unknown"), payload=_parse_payload(content)))
|
|
84
|
+
return Transcript(steps=steps)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def from_anthropic(messages: list[dict[str, Any]]) -> Transcript:
|
|
88
|
+
"""Anthropic messages API, with content as a string or as blocks."""
|
|
89
|
+
steps: list[Step] = []
|
|
90
|
+
tool_names: dict[str, str] = {}
|
|
91
|
+
for m in messages:
|
|
92
|
+
content = m.get("content")
|
|
93
|
+
blocks: list[dict[str, Any]] = (
|
|
94
|
+
[{"type": "text", "text": content}] if isinstance(content, str) else content if isinstance(content, list) else []
|
|
95
|
+
)
|
|
96
|
+
if m.get("role") == "user":
|
|
97
|
+
for b in blocks:
|
|
98
|
+
if b.get("type") == "text" and isinstance(b.get("text"), str):
|
|
99
|
+
steps.append(Step(role="user", content=b["text"]))
|
|
100
|
+
if b.get("type") == "tool_result":
|
|
101
|
+
tid = str(b.get("tool_use_id", ""))
|
|
102
|
+
inner = b.get("content")
|
|
103
|
+
if isinstance(inner, list):
|
|
104
|
+
inner = "".join(x.get("text", "") if isinstance(x.get("text"), str) else "" for x in inner)
|
|
105
|
+
steps.append(
|
|
106
|
+
Step(
|
|
107
|
+
role="tool_result",
|
|
108
|
+
id=tid,
|
|
109
|
+
tool=tool_names.get(tid, "unknown"),
|
|
110
|
+
status="error" if b.get("is_error") is True else "ok",
|
|
111
|
+
payload=_parse_payload(inner),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
elif m.get("role") == "assistant":
|
|
115
|
+
for b in blocks:
|
|
116
|
+
if b.get("type") == "tool_use":
|
|
117
|
+
tid = str(b.get("id", ""))
|
|
118
|
+
tool = str(b.get("name", "unknown"))
|
|
119
|
+
tool_names[tid] = tool
|
|
120
|
+
steps.append(Step(role="tool_call", id=tid, tool=tool, args=_parse_args(b.get("input"))))
|
|
121
|
+
elif b.get("type") == "text" and isinstance(b.get("text"), str) and b["text"].strip():
|
|
122
|
+
steps.append(Step(role="assistant", content=b["text"]))
|
|
123
|
+
return Transcript(steps=steps)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def to_transcript(data: Any) -> Transcript:
|
|
127
|
+
"""Accept any of the three shapes: agentguard's own ``{"steps": [...]}``, an OpenAI message
|
|
128
|
+
list, or an Anthropic message list (bare, or under ``"messages"``)."""
|
|
129
|
+
if isinstance(data, dict) and isinstance(data.get("steps"), list):
|
|
130
|
+
return load_transcript(data)
|
|
131
|
+
if isinstance(data, list):
|
|
132
|
+
messages = data
|
|
133
|
+
elif isinstance(data, dict) and isinstance(data.get("messages"), list):
|
|
134
|
+
messages = data["messages"]
|
|
135
|
+
else:
|
|
136
|
+
messages = []
|
|
137
|
+
looks_anthropic = any(
|
|
138
|
+
isinstance(m.get("content"), list)
|
|
139
|
+
and any(isinstance(b, dict) and b.get("type") in ("tool_use", "tool_result") for b in m["content"])
|
|
140
|
+
for m in messages
|
|
141
|
+
)
|
|
142
|
+
return from_anthropic(messages) if looks_anthropic else from_openai(messages)
|