agent-eval-flow 0.5.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_eval_flow/__init__.py +23 -0
- agent_eval_flow/adapters/__init__.py +21 -0
- agent_eval_flow/adapters/claude_code.py +134 -0
- agent_eval_flow/adapters/cli.py +433 -0
- agent_eval_flow/adapters/codex.py +97 -0
- agent_eval_flow/adapters/common.py +81 -0
- agent_eval_flow/adapters/configuration_files_assessment.py +157 -0
- agent_eval_flow/adapters/harbor.py +305 -0
- agent_eval_flow/adapters/harness_eval_assessment.py +325 -0
- agent_eval_flow/adapters/nat.py +180 -0
- agent_eval_flow/adapters/openkritt.py +709 -0
- agent_eval_flow/adapters/opensre.py +509 -0
- agent_eval_flow/adapters/process.py +308 -0
- agent_eval_flow/adapters/skillevaluator.py +126 -0
- agent_eval_flow/adapters/snapshot_binding_assessment.py +148 -0
- agent_eval_flow/adapters/worker.py +366 -0
- agent_eval_flow/evaluation/__init__.py +2 -0
- agent_eval_flow/evaluation/aggregation.py +116 -0
- agent_eval_flow/evaluation/assessment_projection.py +57 -0
- agent_eval_flow/evaluation/compiler.py +134 -0
- agent_eval_flow/evaluation/configuration.py +103 -0
- agent_eval_flow/evaluation/engine.py +333 -0
- agent_eval_flow/evaluation/primitives.py +26 -0
- agent_eval_flow/evaluation/runtime_checks.py +172 -0
- agent_eval_flow/evaluation/scoring.py +85 -0
- agent_eval_flow/execution/__init__.py +4 -0
- agent_eval_flow/execution/capture.py +331 -0
- agent_eval_flow/execution/configuration_assessment.py +190 -0
- agent_eval_flow/execution/dispatch_assessment.py +324 -0
- agent_eval_flow/execution/importing.py +106 -0
- agent_eval_flow/execution/planning.py +72 -0
- agent_eval_flow/execution/preflight.py +99 -0
- agent_eval_flow/execution/runner.py +169 -0
- agent_eval_flow/execution/snapshot_assessment.py +14 -0
- agent_eval_flow/execution/snapshot_binding_assessment.py +118 -0
- agent_eval_flow/objects/__init__.py +10 -0
- agent_eval_flow/objects/assessment.py +522 -0
- agent_eval_flow/objects/assessment_validation.py +651 -0
- agent_eval_flow/objects/base.py +79 -0
- agent_eval_flow/objects/candidate.py +33 -0
- agent_eval_flow/objects/dataset.py +121 -0
- agent_eval_flow/objects/errors.py +33 -0
- agent_eval_flow/objects/identity.py +118 -0
- agent_eval_flow/objects/records.py +871 -0
- agent_eval_flow/objects/runset.py +316 -0
- agent_eval_flow/objects/runtime_evidence.py +70 -0
- agent_eval_flow/objects/validation.py +141 -0
- agent_eval_flow/objects/values.py +57 -0
- agent_eval_flow/pipeline/__init__.py +1 -0
- agent_eval_flow/pipeline/api.py +68 -0
- agent_eval_flow/pipeline/assessment.py +284 -0
- agent_eval_flow/pipeline/assessment_bindings.py +26 -0
- agent_eval_flow/pipeline/assessment_preflight.py +105 -0
- agent_eval_flow/pipeline/bindings.py +22 -0
- agent_eval_flow/pipeline/preflight.py +72 -0
- agent_eval_flow/py.typed +0 -0
- agent_eval_flow/reporting/__init__.py +1 -0
- agent_eval_flow/reporting/assessment_html.py +53 -0
- agent_eval_flow/reporting/html.py +150 -0
- agent_eval_flow/reporting/templates/assessment_report.html.j2 +67 -0
- agent_eval_flow/reporting/templates/report.css +1 -0
- agent_eval_flow/reporting/templates/report.html.j2 +118 -0
- agent_eval_flow/results/__init__.py +1 -0
- agent_eval_flow/results/assessment_comparison.py +74 -0
- agent_eval_flow/results/assessment_query.py +74 -0
- agent_eval_flow/results/assessment_selection.py +160 -0
- agent_eval_flow/results/comparison.py +102 -0
- agent_eval_flow/results/query.py +159 -0
- agent_eval_flow/results/selection.py +193 -0
- agent_eval_flow/storage/__init__.py +1 -0
- agent_eval_flow/storage/artifacts.py +131 -0
- agent_eval_flow/storage/assessment_codec.py +107 -0
- agent_eval_flow/storage/assessment_manifests.py +38 -0
- agent_eval_flow/storage/codec.py +296 -0
- agent_eval_flow/storage/manifests.py +111 -0
- agent_eval_flow-0.5.1.dist-info/METADATA +197 -0
- agent_eval_flow-0.5.1.dist-info/RECORD +79 -0
- agent_eval_flow-0.5.1.dist-info/WHEEL +5 -0
- agent_eval_flow-0.5.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Agent Eval Flow: configure systems, capture evidence, evaluate saved runs."""
|
|
2
|
+
from .objects import *
|
|
3
|
+
from .objects.records import __all__ as _record_exports
|
|
4
|
+
from .objects.errors import __all__ as _error_exports
|
|
5
|
+
from .objects.assessment import __all__ as _assessment_exports
|
|
6
|
+
|
|
7
|
+
__all__ = [*_record_exports, *_error_exports, *_assessment_exports,
|
|
8
|
+
"EvaluationPipeline", "AssessmentPipeline", "evaluate", "wrap_behavior_result", "__version__"]
|
|
9
|
+
|
|
10
|
+
__version__ = "0.5.1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name):
|
|
14
|
+
if name == "AssessmentPipeline":
|
|
15
|
+
from .pipeline.assessment import AssessmentPipeline
|
|
16
|
+
return AssessmentPipeline
|
|
17
|
+
if name == "wrap_behavior_result":
|
|
18
|
+
from .evaluation.assessment_projection import wrap_behavior_result
|
|
19
|
+
return wrap_behavior_result
|
|
20
|
+
if name in ("EvaluationPipeline", "evaluate"):
|
|
21
|
+
from .pipeline import api
|
|
22
|
+
return getattr(api, name)
|
|
23
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Optional runtime adapters. Import a concrete module to bind a runtime.
|
|
2
|
+
|
|
3
|
+
Importing agent_eval_flow never loads or starts an optional agent framework.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__all__ = ["FileConfigurationCollector", "HarnessEvalConfigurationEvaluator", "CallbackSnapshotBinder"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def __getattr__(name):
|
|
10
|
+
# Keep construction and ordinary package import free of scanner/process work.
|
|
11
|
+
modules = {
|
|
12
|
+
"FileConfigurationCollector": ".configuration_files_assessment",
|
|
13
|
+
"HarnessEvalConfigurationEvaluator": ".harness_eval_assessment",
|
|
14
|
+
"CallbackSnapshotBinder": ".snapshot_binding_assessment",
|
|
15
|
+
}
|
|
16
|
+
if name not in modules:
|
|
17
|
+
raise AttributeError(name)
|
|
18
|
+
from importlib import import_module
|
|
19
|
+
value = getattr(import_module(modules[name], __name__), name)
|
|
20
|
+
globals()[name] = value
|
|
21
|
+
return value
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Claude Code stream-json mapping with source-linked tool exchanges."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from .. import objects as o
|
|
7
|
+
from ..objects.identity import plain
|
|
8
|
+
from ..objects.values import unknown
|
|
9
|
+
from .cli import CliCapture, NativeCliBackend, native_token, parse_jsonl
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ClaudeDialect:
|
|
14
|
+
revision: str = "stream-json/1"
|
|
15
|
+
profile: str = "claude_local"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def accepted_settings(self):
|
|
19
|
+
return frozenset({"tools", "allowed_tools", "max_turns"})
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def capture_files(self):
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
def validate_native(self, config):
|
|
26
|
+
if config.schema_ref != o.VersionRef(name="claude-code.print", revision=self.revision) or config.values:
|
|
27
|
+
raise o.ConfigurationError("Claude dialect accepts claude-code.print/stream-json/1; use candidate.settings")
|
|
28
|
+
|
|
29
|
+
def validate_connection(self, model, environment):
|
|
30
|
+
if model["provider"] != "anthropic":
|
|
31
|
+
raise o.ConfigurationError("This Claude dialect supports the direct Anthropic provider only")
|
|
32
|
+
for flag in ("CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_FOUNDRY"):
|
|
33
|
+
if environment.get(flag, "").lower() not in ("", "0", "false"):
|
|
34
|
+
raise o.ConfigurationError(f"{flag} conflicts with the declared direct Anthropic provider")
|
|
35
|
+
endpoint = environment.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com").rstrip("/")
|
|
36
|
+
if endpoint != "https://api.anthropic.com":
|
|
37
|
+
raise o.ConfigurationError("An alternate Anthropic endpoint requires its own verified provider dialect")
|
|
38
|
+
|
|
39
|
+
def argv(self, executable, work, model, settings, schema):
|
|
40
|
+
if model["provider"] != "anthropic":
|
|
41
|
+
raise o.ConfigurationError("This Claude dialect supports the direct Anthropic provider only")
|
|
42
|
+
args = [str(executable), "--bare", "-p", "--output-format", "stream-json", "--verbose", "--model", model["id"]]
|
|
43
|
+
if schema is not None:
|
|
44
|
+
args += ["--json-schema", json.dumps(plain(schema), allow_nan=False)]
|
|
45
|
+
turns = settings.get("max_turns")
|
|
46
|
+
if turns is not None:
|
|
47
|
+
if type(turns) is not int or turns <= 0:
|
|
48
|
+
raise o.ConfigurationError("max_turns must be a positive integer")
|
|
49
|
+
args += ["--max-turns", str(turns)]
|
|
50
|
+
for key, flag in (("tools", "--tools"), ("allowed_tools", "--allowedTools")):
|
|
51
|
+
values = settings.get(key)
|
|
52
|
+
if values is not None:
|
|
53
|
+
if not isinstance(values, (list, tuple)) or not all(isinstance(value, str) for value in values):
|
|
54
|
+
raise o.ConfigurationError(f"{key} must be an array of tool names")
|
|
55
|
+
args += [flag, ",".join(values)]
|
|
56
|
+
return tuple(args)
|
|
57
|
+
|
|
58
|
+
def parse(self, capture):
|
|
59
|
+
rows, issues = parse_jsonl(capture.stdout)
|
|
60
|
+
terminals = tuple(row for row in rows if row.value.get("type") == "result")
|
|
61
|
+
terminal = terminals[-1] if terminals else None
|
|
62
|
+
refs = {}
|
|
63
|
+
for row in rows:
|
|
64
|
+
if isinstance(row.value.get("session_id"), str):
|
|
65
|
+
refs["invocation_id"] = row.value["session_id"]
|
|
66
|
+
output, state = None, "unavailable"
|
|
67
|
+
if terminal:
|
|
68
|
+
value = terminal.value
|
|
69
|
+
if "structured_output" in value:
|
|
70
|
+
output, state = value["structured_output"], "available"
|
|
71
|
+
elif "result" in value:
|
|
72
|
+
# Unstructured text is still an available native output.
|
|
73
|
+
output, state = value["result"], "available"
|
|
74
|
+
return CliCapture(rows, terminal, output, state, refs, issues)
|
|
75
|
+
|
|
76
|
+
def status(self, native, capture):
|
|
77
|
+
if native.terminal:
|
|
78
|
+
value = native.terminal.value
|
|
79
|
+
return "completed" if value.get("subtype") == "success" and not value.get("is_error") else "agent_error"
|
|
80
|
+
return "infrastructure_error"
|
|
81
|
+
|
|
82
|
+
def resolved_model(self, native):
|
|
83
|
+
return next((row.value.get("model") for row in native.events
|
|
84
|
+
if row.value.get("type") == "system" and row.value.get("subtype") == "init"), None)
|
|
85
|
+
|
|
86
|
+
def resources(self, native):
|
|
87
|
+
terminal = native.terminal
|
|
88
|
+
value = terminal.value if terminal else {}
|
|
89
|
+
usage, source = value.get("usage", {}), terminal.source if terminal else None
|
|
90
|
+
if not isinstance(usage, dict):
|
|
91
|
+
usage = {}
|
|
92
|
+
cost = unknown("Claude export did not expose a cost estimate")
|
|
93
|
+
native_cost = value.get("total_cost_usd")
|
|
94
|
+
if type(native_cost) in (int, float) and Decimal(str(native_cost)).is_finite() and native_cost >= 0:
|
|
95
|
+
# Documented client-side estimate, not an observed provider invoice.
|
|
96
|
+
cost = o.Observation(value=Decimal(str(native_cost)), status="estimated",
|
|
97
|
+
reason="Claude Code client-side total_cost_usd estimate", evidence=(source,))
|
|
98
|
+
return o.Resources(cost_usd=cost, cost_scope=("model",),
|
|
99
|
+
input_tokens=native_token(usage.get("input_tokens"), source),
|
|
100
|
+
output_tokens=native_token(usage.get("output_tokens"), source),
|
|
101
|
+
human_minutes=unknown("Human effort is not measured by the CLI export"))
|
|
102
|
+
|
|
103
|
+
def events(self, native, execution_id):
|
|
104
|
+
events, choices = [], {}
|
|
105
|
+
for index, row in enumerate(native.events):
|
|
106
|
+
value = row.value
|
|
107
|
+
message = value.get("message", {})
|
|
108
|
+
content = message.get("content", ()) if isinstance(message, dict) else ()
|
|
109
|
+
event_id = f"{execution_id}/event/{index}"
|
|
110
|
+
if value.get("type") == "assistant" and isinstance(content, list):
|
|
111
|
+
for part in content:
|
|
112
|
+
if isinstance(part, dict) and part.get("type") == "tool_use":
|
|
113
|
+
choices[part.get("id")] = (part, event_id, row.source)
|
|
114
|
+
events.append(o.Event(id=event_id, execution_id=execution_id, kind="model_response", at=None,
|
|
115
|
+
fields=value, source=row.source))
|
|
116
|
+
elif value.get("type") == "user" and isinstance(content, list):
|
|
117
|
+
for number, part in enumerate(content):
|
|
118
|
+
if not isinstance(part, dict) or part.get("type") != "tool_result":
|
|
119
|
+
continue
|
|
120
|
+
prior = choices.get(part.get("tool_use_id"))
|
|
121
|
+
events.append(o.Event(id=f"{event_id}/tool/{number}", execution_id=execution_id, kind="tool_call", at=None,
|
|
122
|
+
fields={"name": prior[0].get("name", "unknown") if prior else "unknown", "native_id": part.get("tool_use_id"),
|
|
123
|
+
"arguments": prior[0].get("input") if prior else None, "result": part.get("content"),
|
|
124
|
+
"is_error": part.get("is_error", False), "decision_event_id": prior[1] if prior else None},
|
|
125
|
+
inputs=(prior[2],) if prior else (), outputs=(row.source,), source=row.source))
|
|
126
|
+
else:
|
|
127
|
+
events.append(o.Event(id=event_id, execution_id=execution_id, kind="native." + str(value.get("type", "unknown")),
|
|
128
|
+
at=None, fields=value, source=row.source))
|
|
129
|
+
return tuple(events)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ClaudeCodeBackend(NativeCliBackend):
|
|
133
|
+
def __init__(self, *, binding, connection, dialect=None, model=None, scenario=None):
|
|
134
|
+
super().__init__(binding=binding, connection=connection, dialect=dialect or ClaudeDialect(), model=model, scenario=scenario)
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Shared native CLI capture machinery; agent-specific schemas stay in dialects."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field, fields, is_dataclass, replace
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
from urllib.request import url2pathname
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
import anyio
|
|
20
|
+
|
|
21
|
+
from .. import objects as o
|
|
22
|
+
from ..objects.identity import plain
|
|
23
|
+
from ..objects.values import observed, unknown
|
|
24
|
+
from .common import AdapterBinding
|
|
25
|
+
from .process import CliConnection, NativeProcessCapture, ProcessLaunch
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def local_path(ref: o.ArtifactRef) -> Path:
|
|
29
|
+
parsed = urlparse(ref.uri)
|
|
30
|
+
if parsed.scheme == "file" and parsed.netloc in ("", "localhost"):
|
|
31
|
+
return Path(url2pathname(parsed.path))
|
|
32
|
+
if not parsed.scheme or (os.name == "nt" and len(parsed.scheme) == 1):
|
|
33
|
+
return Path(ref.uri)
|
|
34
|
+
raise o.ConfigurationError("CLI assets must be materialized locally before dispatch")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def json_bytes(value) -> bytes:
|
|
38
|
+
return (json.dumps(plain(value), ensure_ascii=False, allow_nan=False, indent=2) + "\n").encode("utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class LocatedRecord:
|
|
43
|
+
value: Mapping
|
|
44
|
+
source: o.EvidenceRef
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CliCapture:
|
|
49
|
+
events: tuple[LocatedRecord, ...]
|
|
50
|
+
terminal: LocatedRecord | None
|
|
51
|
+
final_output: o.JSONValue
|
|
52
|
+
output_state: str
|
|
53
|
+
native_refs: Mapping[str, str]
|
|
54
|
+
issues: tuple[str, ...] = ()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class PreparedCli:
|
|
59
|
+
launch: ProcessLaunch
|
|
60
|
+
model: Mapping[str, str]
|
|
61
|
+
runtime_revision: str
|
|
62
|
+
artifacts: Mapping[str, o.ArtifactRef]
|
|
63
|
+
schema: Mapping | bool | None
|
|
64
|
+
skill_loads: tuple[Mapping, ...] = ()
|
|
65
|
+
metadata: Mapping = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CliScenario(Protocol):
|
|
69
|
+
"""A caller-owned asset/mission recipe; it cannot manufacture native events."""
|
|
70
|
+
def prepare(self, request: o.RunRequest, workspace: Path, artifacts) -> Mapping: ...
|
|
71
|
+
def finish(self, request: o.RunRequest, prepared: PreparedCli, run: o.Run, artifacts) -> o.Run: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_jsonl(ref: o.ArtifactRef) -> tuple[tuple[LocatedRecord, ...], tuple[str, ...]]:
|
|
75
|
+
rows, issues = [], []
|
|
76
|
+
# Stream the retained source, never replace it with normalized rows.
|
|
77
|
+
with local_path(ref).open("rb") as stream:
|
|
78
|
+
for number, line in enumerate(stream, 1):
|
|
79
|
+
if not line.strip():
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
value = json.loads(line, parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value)))
|
|
83
|
+
if not isinstance(value, dict):
|
|
84
|
+
raise ValueError("native event must be an object")
|
|
85
|
+
except (ValueError, TypeError, UnicodeError) as exc:
|
|
86
|
+
issues.append(f"line:{number}: {exc}")
|
|
87
|
+
continue
|
|
88
|
+
rows.append(LocatedRecord(value, o.EvidenceRef(artifact=ref, locator=f"line:{number}")))
|
|
89
|
+
return tuple(rows), tuple(issues)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def json_output(ref: o.ArtifactRef | None):
|
|
93
|
+
if ref is None:
|
|
94
|
+
return None, "unavailable", ()
|
|
95
|
+
try:
|
|
96
|
+
text = local_path(ref).read_text(encoding="utf-8")
|
|
97
|
+
value = json.loads(text,
|
|
98
|
+
parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value)))
|
|
99
|
+
return value, "available", ()
|
|
100
|
+
except UnicodeError as exc:
|
|
101
|
+
return None, "unknown", (f"Delivered response cannot be decoded as UTF-8: {exc}",)
|
|
102
|
+
except ValueError:
|
|
103
|
+
# Native final text remains an available output even when JSON was not
|
|
104
|
+
# produced. A configured response schema reports that separately.
|
|
105
|
+
return text, "available", ()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def native_token(value, source):
|
|
109
|
+
if type(value) is int and value >= 0:
|
|
110
|
+
return observed(value, evidence=(source,))
|
|
111
|
+
return unknown("The native export did not expose this token total")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def schema_validator(schema):
|
|
115
|
+
if schema is None:
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
from jsonschema.validators import validator_for
|
|
119
|
+
from referencing import Registry
|
|
120
|
+
except ImportError as exc:
|
|
121
|
+
raise o.ConfigurationError("Install agent-eval-flow[cli] for structured response validation") from exc
|
|
122
|
+
schema = plain(schema)
|
|
123
|
+
try:
|
|
124
|
+
cls = validator_for(schema)
|
|
125
|
+
cls.check_schema(schema)
|
|
126
|
+
# External references require caller materialization; never fetch a schema implicitly.
|
|
127
|
+
return cls(schema, registry=Registry())
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
raise o.ConfigurationError(f"Invalid response_schema: {exc}") from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def materialize_owned_evidence(value, workspace, cache, _seen=None):
|
|
133
|
+
"""Retain referenced invocation files before deleting only the owned workspace."""
|
|
134
|
+
if _seen is None:
|
|
135
|
+
_seen = {}
|
|
136
|
+
if isinstance(value, o.ArtifactRef):
|
|
137
|
+
key = (value.uri, value.media_type, value.sha256)
|
|
138
|
+
if key in _seen:
|
|
139
|
+
return _seen[key]
|
|
140
|
+
try:
|
|
141
|
+
path = local_path(value).resolve()
|
|
142
|
+
except o.ConfigurationError:
|
|
143
|
+
return value
|
|
144
|
+
if not path.is_relative_to(workspace):
|
|
145
|
+
return value
|
|
146
|
+
if not path.is_file():
|
|
147
|
+
raise o.StorageError(f"Invocation evidence is not a retained regular file: {path}")
|
|
148
|
+
with path.open("rb") as stream:
|
|
149
|
+
retained = cache.write_stream("invocation-evidence", stream, value.media_type)
|
|
150
|
+
if value.sha256 is not None and retained.sha256 != value.sha256:
|
|
151
|
+
raise o.StorageError(f"Invocation evidence changed before retention: {path}")
|
|
152
|
+
_seen[key] = retained
|
|
153
|
+
return retained
|
|
154
|
+
if is_dataclass(value):
|
|
155
|
+
return replace(value, **{item.name: materialize_owned_evidence(getattr(value, item.name), workspace, cache, _seen)
|
|
156
|
+
for item in fields(value)})
|
|
157
|
+
if isinstance(value, Mapping):
|
|
158
|
+
return {key: materialize_owned_evidence(item, workspace, cache, _seen) for key, item in value.items()}
|
|
159
|
+
if isinstance(value, (tuple, list)):
|
|
160
|
+
return tuple(materialize_owned_evidence(item, workspace, cache, _seen) for item in value)
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class NativeCliBackend:
|
|
165
|
+
"""One bounded native CLI assignment, with immutable raw evidence.
|
|
166
|
+
|
|
167
|
+
Bind an executable, a verified contained launcher, and a versioned dialect.
|
|
168
|
+
Per-candidate settings: mission, response_schema, model {provider,id},
|
|
169
|
+
sandbox (Codex), tools/allowed_tools/max_turns (Claude). Additional scenario
|
|
170
|
+
recipes are runtime bindings; their behavior must be declared in Candidate.
|
|
171
|
+
Unknown settings require a scenario's explicit accepted_settings declaration.
|
|
172
|
+
The reserved metadata mapping is bookkeeping only and does not configure or
|
|
173
|
+
prompt the native agent. Successful owned workspaces are removed after all
|
|
174
|
+
typed evidence references are retained; failures remain for investigation.
|
|
175
|
+
"""
|
|
176
|
+
def __init__(self, *, binding: AdapterBinding, connection: CliConnection,
|
|
177
|
+
dialect, model: Mapping[str, str] | None = None, scenario: CliScenario | None = None):
|
|
178
|
+
self.binding, self.connection, self.dialect = binding, connection, dialect
|
|
179
|
+
self.ref, self.scenario = binding.ref, scenario
|
|
180
|
+
self.default_model = dict(model or {})
|
|
181
|
+
if not binding.ref.revision or not binding.upstream_ref.revision:
|
|
182
|
+
raise o.ConfigurationError("CLI adapter and upstream runtime need explicit revisions")
|
|
183
|
+
|
|
184
|
+
def capabilities(self):
|
|
185
|
+
return o.BackendCapabilities(wall_time_limit=self.connection.supervisor.hard_wall_time_limit,
|
|
186
|
+
token_limit=False, cost_limit=False, reset_state=True)
|
|
187
|
+
|
|
188
|
+
def run(self, request, *, recorder):
|
|
189
|
+
return anyio.from_thread.run(self._run, request, recorder)
|
|
190
|
+
|
|
191
|
+
def _runtime_revision(self):
|
|
192
|
+
executable = Path(self.connection.executable)
|
|
193
|
+
if not executable.is_absolute() or not executable.is_file():
|
|
194
|
+
raise o.ConfigurationError("CLI executable must resolve to an existing absolute path")
|
|
195
|
+
try:
|
|
196
|
+
version = subprocess.run([str(executable), "--version"], capture_output=True, text=True,
|
|
197
|
+
encoding="utf-8", timeout=10, check=True,
|
|
198
|
+
env=dict(self.connection.environment)).stdout.strip()
|
|
199
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
200
|
+
raise o.ConfigurationError(f"Cannot establish native CLI version: {exc}") from exc
|
|
201
|
+
# Match a whole printed token, never a permissive substring such as 1.2 in 1.20.
|
|
202
|
+
expected = self.binding.upstream_ref.revision
|
|
203
|
+
if expected != version and expected not in version.split():
|
|
204
|
+
raise o.ConfigurationError(f"Expected CLI revision {expected!r}, observed {version!r}")
|
|
205
|
+
return version
|
|
206
|
+
|
|
207
|
+
def prepare(self, request):
|
|
208
|
+
if request.candidate.backend != self.ref:
|
|
209
|
+
raise o.ConfigurationError("Candidate backend differs from this CLI binding")
|
|
210
|
+
if request.candidate.native is not None:
|
|
211
|
+
self.dialect.validate_native(request.candidate.native)
|
|
212
|
+
settings = request.candidate.settings
|
|
213
|
+
allowed = {"mission", "response_schema", "model", "metadata"}
|
|
214
|
+
allowed.update(getattr(self.dialect, "accepted_settings", ()))
|
|
215
|
+
if self.scenario is not None:
|
|
216
|
+
allowed.update(getattr(self.scenario, "accepted_settings", ()))
|
|
217
|
+
unsupported = set(settings) - allowed
|
|
218
|
+
if unsupported:
|
|
219
|
+
raise o.ConfigurationError(f"Unsupported native CLI settings: {', '.join(sorted(unsupported))}")
|
|
220
|
+
if "metadata" in settings and not isinstance(settings["metadata"], Mapping):
|
|
221
|
+
raise o.ConfigurationError("CLI metadata must be a bookkeeping mapping")
|
|
222
|
+
if "runtime_revision" in settings and settings["runtime_revision"] != self.binding.upstream_ref.revision:
|
|
223
|
+
raise o.ConfigurationError("Declared runtime_revision differs from the prepared runtime binding")
|
|
224
|
+
if "executable" in settings:
|
|
225
|
+
if not isinstance(settings["executable"], str) or Path(settings["executable"]).resolve() != Path(self.connection.executable).resolve():
|
|
226
|
+
raise o.ConfigurationError("Declared executable differs from the prepared CLI connection")
|
|
227
|
+
revision = self._runtime_revision()
|
|
228
|
+
root = Path(self.binding.workspace_root).resolve()
|
|
229
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
work = root / (hashlib.sha256(request.run_id.encode()).hexdigest()[:16] + "-" + uuid4().hex)
|
|
231
|
+
work.mkdir(exist_ok=False)
|
|
232
|
+
artifacts = {}
|
|
233
|
+
configured_model = settings.get("model", {})
|
|
234
|
+
if not isinstance(configured_model, Mapping):
|
|
235
|
+
raise o.ConfigurationError("candidate.settings.model must be a mapping")
|
|
236
|
+
model = dict(configured_model)
|
|
237
|
+
models = [c for c in request.candidate.components.values() if c.kind == "model"]
|
|
238
|
+
if len(models) > 1:
|
|
239
|
+
raise o.ConfigurationError("CLI direct binding accepts one model component")
|
|
240
|
+
if models:
|
|
241
|
+
declared = dict(models[0].params)
|
|
242
|
+
declared.setdefault("id", models[0].ref.name)
|
|
243
|
+
if model and any(model.get(k) != declared.get(k) for k in ("id", "provider") if k in declared):
|
|
244
|
+
raise o.ConfigurationError("Conflicting model component and settings/binding")
|
|
245
|
+
model.update(declared)
|
|
246
|
+
if set(model) - {"id", "provider"}:
|
|
247
|
+
raise o.ConfigurationError("This CLI dialect implements only model id/provider; other model options are unsupported")
|
|
248
|
+
if not all(isinstance(model.get(k), str) and model[k].strip() for k in ("id", "provider")):
|
|
249
|
+
raise o.ConfigurationError("Declare model id/provider in candidate.settings.model or a model component; binding defaults cannot define candidate behavior")
|
|
250
|
+
if self.default_model and any(model.get(k) != value for k, value in self.default_model.items()):
|
|
251
|
+
raise o.ConfigurationError("Declared model differs from the prepared model binding")
|
|
252
|
+
if hasattr(self.dialect, "validate_connection"):
|
|
253
|
+
self.dialect.validate_connection(model, self.connection.environment)
|
|
254
|
+
schema = settings.get("response_schema")
|
|
255
|
+
mission = settings.get("mission", "Perform the supplied task and return your result as JSON.")
|
|
256
|
+
if not isinstance(mission, str):
|
|
257
|
+
raise o.ConfigurationError("mission must be text")
|
|
258
|
+
input_ref = self.binding.artifacts.write_bytes("agent.input", json_bytes(request.input.tables), "application/json")
|
|
259
|
+
artifacts["agent.input"] = input_ref
|
|
260
|
+
(work / "input.json").write_bytes(local_path(input_ref).read_bytes())
|
|
261
|
+
chunks = [mission, "Public task input:\n" + json_bytes(request.input.tables).decode()]
|
|
262
|
+
skills = []
|
|
263
|
+
component_root = work / "components"
|
|
264
|
+
component_root.mkdir()
|
|
265
|
+
for slot, component in request.candidate.components.items():
|
|
266
|
+
if component.content is None:
|
|
267
|
+
continue
|
|
268
|
+
source = local_path(component.content)
|
|
269
|
+
suffix = source.suffix if re.fullmatch(r"\.[a-zA-Z0-9]{1,12}", source.suffix) else ".data"
|
|
270
|
+
destination = component_root / (hashlib.sha256(slot.encode()).hexdigest()[:16] + suffix)
|
|
271
|
+
try:
|
|
272
|
+
data = source.read_bytes()
|
|
273
|
+
except OSError as exc:
|
|
274
|
+
raise o.ConfigurationError(f"Cannot read declared component {slot!r}: {exc}") from exc
|
|
275
|
+
if not component.content.sha256 or hashlib.sha256(data).hexdigest() != component.content.sha256:
|
|
276
|
+
raise o.ConfigurationError(f"Declared component {slot!r} bytes do not match its pinned content hash")
|
|
277
|
+
destination.write_bytes(data)
|
|
278
|
+
ref = self.binding.artifacts.write_bytes(slot, data, component.content.media_type)
|
|
279
|
+
artifacts["component." + slot] = ref
|
|
280
|
+
if component.kind in ("skill", "prompt"):
|
|
281
|
+
try:
|
|
282
|
+
content = data.decode("utf-8")
|
|
283
|
+
except UnicodeError as exc:
|
|
284
|
+
raise o.ConfigurationError(f"Declared {component.kind} {slot!r} is not UTF-8 text") from exc
|
|
285
|
+
chunks.append(f"Declared {component.kind} {slot}:\n{content}")
|
|
286
|
+
if component.kind == "skill":
|
|
287
|
+
match = re.search(r"^name:\s*([^\r\n]+)$", content, re.MULTILINE)
|
|
288
|
+
skills.append({"slot": slot, "name": match.group(1).strip() if match else component.ref.name,
|
|
289
|
+
"sha256": ref.sha256, "artifact": "component." + slot})
|
|
290
|
+
else:
|
|
291
|
+
chunks.append(f"Declared {component.kind} {slot}: {destination.relative_to(work).as_posix()}\n"
|
|
292
|
+
f"Parameters: {json_bytes(component.params).decode()}")
|
|
293
|
+
metadata = {}
|
|
294
|
+
if self.scenario is not None:
|
|
295
|
+
metadata = dict(self.scenario.prepare(request, work, self.binding.artifacts))
|
|
296
|
+
mission_extra = metadata.get("mission", "")
|
|
297
|
+
if not isinstance(mission_extra, str):
|
|
298
|
+
raise o.ConfigurationError("Scenario mission must be text")
|
|
299
|
+
chunks.append(mission_extra)
|
|
300
|
+
schema = metadata.get("response_schema", schema)
|
|
301
|
+
artifacts.update(metadata.get("artifacts", {}))
|
|
302
|
+
schema_validator(schema)
|
|
303
|
+
if schema is not None:
|
|
304
|
+
(work / "response.schema.json").write_bytes(json_bytes(schema))
|
|
305
|
+
prompt = ("\n\n".join(chunks) + "\n").encode("utf-8")
|
|
306
|
+
artifacts["native.prompt"] = self.binding.artifacts.write_bytes("native.prompt", prompt, "text/plain")
|
|
307
|
+
argv = self.dialect.argv(self.connection.executable, work, model, settings, schema)
|
|
308
|
+
launch = ProcessLaunch(argv=argv, stdin=prompt, workspace=work,
|
|
309
|
+
environment=self.connection.environment, wall_time_s=request.policy.budget.wall_time_s,
|
|
310
|
+
capture_files=self.dialect.capture_files)
|
|
311
|
+
return PreparedCli(launch, model, revision, artifacts, schema, tuple(skills), metadata)
|
|
312
|
+
|
|
313
|
+
async def _run(self, request, recorder):
|
|
314
|
+
prepared = await anyio.to_thread.run_sync(self.prepare, request)
|
|
315
|
+
try:
|
|
316
|
+
capture = await self.connection.supervisor.execute(prepared.launch)
|
|
317
|
+
except BaseException as exc:
|
|
318
|
+
for name, artifact in {**prepared.artifacts, **getattr(exc, "artifacts", {})}.items():
|
|
319
|
+
recorder.record_artifact(name, artifact)
|
|
320
|
+
raise
|
|
321
|
+
prepared = replace(prepared, artifacts=await anyio.to_thread.run_sync(
|
|
322
|
+
materialize_owned_evidence, prepared.artifacts, prepared.launch.workspace, self.binding.artifacts))
|
|
323
|
+
for name, artifact in {**prepared.artifacts, **capture.artifacts,
|
|
324
|
+
"native.trace": capture.stdout, "native.stderr": capture.stderr}.items():
|
|
325
|
+
recorder.record_artifact(name, artifact)
|
|
326
|
+
try:
|
|
327
|
+
native = self.dialect.parse(capture)
|
|
328
|
+
except (ValueError, TypeError, KeyError, AttributeError) as exc:
|
|
329
|
+
native = CliCapture(events=(), terminal=None, final_output=None, output_state="unknown",
|
|
330
|
+
native_refs={}, issues=(f"Native trace projection failed: {type(exc).__name__}: {exc}",))
|
|
331
|
+
run = self.map_capture(request, capture, native, prepared)
|
|
332
|
+
# Publish terminal evidence before optional scenario exports can fail.
|
|
333
|
+
for name, artifact in run.artifacts.items():
|
|
334
|
+
recorder.record_artifact(name, artifact)
|
|
335
|
+
for execution in run.executions:
|
|
336
|
+
recorder.record_execution(execution)
|
|
337
|
+
if self.scenario is not None:
|
|
338
|
+
try:
|
|
339
|
+
run = await anyio.to_thread.run_sync(self.scenario.finish, request, prepared, run, self.binding.artifacts)
|
|
340
|
+
except BaseException:
|
|
341
|
+
# A scenario can enrich a projected event using its native tool
|
|
342
|
+
# receipt. Publish each final event only once; preserve the base
|
|
343
|
+
# projection if that enrichment/export fails.
|
|
344
|
+
for event in run.events:
|
|
345
|
+
recorder.record_event(event)
|
|
346
|
+
raise
|
|
347
|
+
run = await anyio.to_thread.run_sync(materialize_owned_evidence, run,
|
|
348
|
+
prepared.launch.workspace, self.binding.artifacts)
|
|
349
|
+
for event in run.events:
|
|
350
|
+
recorder.record_event(event)
|
|
351
|
+
if capture.stop.confirmed:
|
|
352
|
+
work, root = prepared.launch.workspace, self.binding.workspace_root.resolve()
|
|
353
|
+
if work.is_symlink() or work.resolve() == root or not work.resolve().is_relative_to(root):
|
|
354
|
+
raise o.ConfigurationError("Refusing cleanup outside the owned invocation directory")
|
|
355
|
+
try:
|
|
356
|
+
await anyio.to_thread.run_sync(shutil.rmtree, work)
|
|
357
|
+
except OSError as exc:
|
|
358
|
+
receipt = self.binding.artifacts.write_bytes("native.cleanup", str(exc).encode("utf-8"), "text/plain")
|
|
359
|
+
run = replace(run, artifacts={**run.artifacts, "native.cleanup": receipt})
|
|
360
|
+
recorder.record_artifact("native.cleanup", receipt)
|
|
361
|
+
return run
|
|
362
|
+
|
|
363
|
+
def map_capture(self, request, capture, native, prepared):
|
|
364
|
+
artifacts = dict(prepared.artifacts)
|
|
365
|
+
artifacts.update(capture.artifacts)
|
|
366
|
+
artifacts.update({"native.trace": capture.stdout, "native.stderr": capture.stderr})
|
|
367
|
+
execution_id = request.run_id + "/native"
|
|
368
|
+
issues = list(native.issues)
|
|
369
|
+
output_state = native.output_state
|
|
370
|
+
if native.output_state == "available" and prepared.schema is not None:
|
|
371
|
+
try:
|
|
372
|
+
schema_validator(prepared.schema).validate(plain(native.final_output))
|
|
373
|
+
except Exception as exc:
|
|
374
|
+
issues.append(f"Response schema: {exc}")
|
|
375
|
+
terminal = native.terminal
|
|
376
|
+
status = self.dialect.status(native, capture)
|
|
377
|
+
if capture.deadline_exceeded:
|
|
378
|
+
status = "timed_out" if capture.stop.confirmed else "infrastructure_error"
|
|
379
|
+
if not capture.stop.confirmed:
|
|
380
|
+
issues.append("Deadline exceeded, but native process-tree stop is unconfirmed")
|
|
381
|
+
elif not capture.stop.confirmed:
|
|
382
|
+
issues.append("Native process-tree termination is unconfirmed; duration remains unknown")
|
|
383
|
+
elif status == "completed" and (issues or capture.output_complete.value is not True):
|
|
384
|
+
# A terminal agent result remains terminal even if projection is incomplete.
|
|
385
|
+
issues.append("Native terminal outcome retained; normalized capture has diagnostics")
|
|
386
|
+
error = None
|
|
387
|
+
if status not in ("completed",) or issues:
|
|
388
|
+
error = o.ErrorRecord(code="native." + status,
|
|
389
|
+
message="; ".join(issues) or f"Native process ended with {status}",
|
|
390
|
+
evidence=(o.EvidenceRef(artifact=capture.stdout), o.EvidenceRef(artifact=capture.stderr)))
|
|
391
|
+
receipt = {
|
|
392
|
+
"profile": self.dialect.profile, "native_id": native.native_refs.get("invocation_id"),
|
|
393
|
+
"runtime_revision": prepared.runtime_revision, "adapter": plain(self.ref),
|
|
394
|
+
"model": prepared.model, "model_basis": "explicit CLI request; resolution may be unexposed",
|
|
395
|
+
"deployment": plain(self.binding.deployment.value) if self.binding.deployment.status == "observed" else None,
|
|
396
|
+
"candidate_fingerprint": request.candidate.fingerprint(), "run_id": request.run_id,
|
|
397
|
+
"argv": prepared.launch.argv, "exit_code": capture.exit_code, "stop": {
|
|
398
|
+
"requested": capture.stop.requested, "confirmed": capture.stop.confirmed, "reason": capture.stop.reason},
|
|
399
|
+
"projection_issues": issues,
|
|
400
|
+
}
|
|
401
|
+
receipt_ref = self.binding.artifacts.write_bytes("native.receipt", json_bytes(receipt), "application/json")
|
|
402
|
+
artifacts["native.receipt"] = receipt_ref
|
|
403
|
+
evidence = o.EvidenceRef(artifact=receipt_ref)
|
|
404
|
+
resources = self.dialect.resources(native)
|
|
405
|
+
if resources.cost_scope != request.policy.cost_scope:
|
|
406
|
+
# Preserve independently observed tokens without relabelling a
|
|
407
|
+
# model-only bill as an observed charge over a broader/different scope.
|
|
408
|
+
resources = o.Resources(cost_usd=unknown(
|
|
409
|
+
"Native CLI usage does not establish cost over the requested categories"),
|
|
410
|
+
cost_scope=request.policy.cost_scope, input_tokens=resources.input_tokens,
|
|
411
|
+
output_tokens=resources.output_tokens, human_minutes=resources.human_minutes)
|
|
412
|
+
execution = o.Execution(id=execution_id, slot="native", retry_index=0, parent_id=None,
|
|
413
|
+
status=status, started_at=capture.started_at, ended_at=capture.ended_at,
|
|
414
|
+
effective_config=observed({"runtime_revision": prepared.runtime_revision,
|
|
415
|
+
"requested_model": prepared.model, "resolved_model": self.dialect.resolved_model(native),
|
|
416
|
+
"candidate_fingerprint": request.candidate.fingerprint()}, evidence=(evidence,)),
|
|
417
|
+
resources=resources, native_refs=native.native_refs, error=error)
|
|
418
|
+
events = []
|
|
419
|
+
for index, skill in enumerate(prepared.skill_loads):
|
|
420
|
+
ref = o.EvidenceRef(artifact=artifacts[skill["artifact"]], description="Exact skill bytes injected in native prompt")
|
|
421
|
+
events.append(o.Event(id=f"{execution_id}/skill/{index}", execution_id=execution_id, kind="skill_loaded",
|
|
422
|
+
at=capture.started_at, fields={k: v for k, v in skill.items() if k != "artifact"},
|
|
423
|
+
inputs=(ref,), outputs=(o.EvidenceRef(artifact=artifacts["native.prompt"]),), source=ref))
|
|
424
|
+
events.extend(self.dialect.events(native, execution_id))
|
|
425
|
+
return o.Run(id=request.run_id, assignment_id=request.assignment.id, status=status,
|
|
426
|
+
cost_scope=request.policy.cost_scope, output=native.final_output, output_state=output_state,
|
|
427
|
+
artifacts=artifacts, executions=(execution,), started_at=capture.started_at,
|
|
428
|
+
ended_at=capture.ended_at, environment=observed({"scopes": {"workspace": {
|
|
429
|
+
"namespace": str(prepared.launch.workspace), "observed_state": "empty at preparation",
|
|
430
|
+
"reset_method": "new-directory"}}, "deployment": receipt["deployment"]}, evidence=(evidence,)),
|
|
431
|
+
execution_inventory_complete=unknown("Native CLI export has aggregate usage; complete child/retry inventory is not established"),
|
|
432
|
+
output_sources=(execution_id,) if output_state == "available" else (),
|
|
433
|
+
native_refs=native.native_refs, events=tuple(events), error=error)
|