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.
Files changed (79) hide show
  1. agent_eval_flow/__init__.py +23 -0
  2. agent_eval_flow/adapters/__init__.py +21 -0
  3. agent_eval_flow/adapters/claude_code.py +134 -0
  4. agent_eval_flow/adapters/cli.py +433 -0
  5. agent_eval_flow/adapters/codex.py +97 -0
  6. agent_eval_flow/adapters/common.py +81 -0
  7. agent_eval_flow/adapters/configuration_files_assessment.py +157 -0
  8. agent_eval_flow/adapters/harbor.py +305 -0
  9. agent_eval_flow/adapters/harness_eval_assessment.py +325 -0
  10. agent_eval_flow/adapters/nat.py +180 -0
  11. agent_eval_flow/adapters/openkritt.py +709 -0
  12. agent_eval_flow/adapters/opensre.py +509 -0
  13. agent_eval_flow/adapters/process.py +308 -0
  14. agent_eval_flow/adapters/skillevaluator.py +126 -0
  15. agent_eval_flow/adapters/snapshot_binding_assessment.py +148 -0
  16. agent_eval_flow/adapters/worker.py +366 -0
  17. agent_eval_flow/evaluation/__init__.py +2 -0
  18. agent_eval_flow/evaluation/aggregation.py +116 -0
  19. agent_eval_flow/evaluation/assessment_projection.py +57 -0
  20. agent_eval_flow/evaluation/compiler.py +134 -0
  21. agent_eval_flow/evaluation/configuration.py +103 -0
  22. agent_eval_flow/evaluation/engine.py +333 -0
  23. agent_eval_flow/evaluation/primitives.py +26 -0
  24. agent_eval_flow/evaluation/runtime_checks.py +172 -0
  25. agent_eval_flow/evaluation/scoring.py +85 -0
  26. agent_eval_flow/execution/__init__.py +4 -0
  27. agent_eval_flow/execution/capture.py +331 -0
  28. agent_eval_flow/execution/configuration_assessment.py +190 -0
  29. agent_eval_flow/execution/dispatch_assessment.py +324 -0
  30. agent_eval_flow/execution/importing.py +106 -0
  31. agent_eval_flow/execution/planning.py +72 -0
  32. agent_eval_flow/execution/preflight.py +99 -0
  33. agent_eval_flow/execution/runner.py +169 -0
  34. agent_eval_flow/execution/snapshot_assessment.py +14 -0
  35. agent_eval_flow/execution/snapshot_binding_assessment.py +118 -0
  36. agent_eval_flow/objects/__init__.py +10 -0
  37. agent_eval_flow/objects/assessment.py +522 -0
  38. agent_eval_flow/objects/assessment_validation.py +651 -0
  39. agent_eval_flow/objects/base.py +79 -0
  40. agent_eval_flow/objects/candidate.py +33 -0
  41. agent_eval_flow/objects/dataset.py +121 -0
  42. agent_eval_flow/objects/errors.py +33 -0
  43. agent_eval_flow/objects/identity.py +118 -0
  44. agent_eval_flow/objects/records.py +871 -0
  45. agent_eval_flow/objects/runset.py +316 -0
  46. agent_eval_flow/objects/runtime_evidence.py +70 -0
  47. agent_eval_flow/objects/validation.py +141 -0
  48. agent_eval_flow/objects/values.py +57 -0
  49. agent_eval_flow/pipeline/__init__.py +1 -0
  50. agent_eval_flow/pipeline/api.py +68 -0
  51. agent_eval_flow/pipeline/assessment.py +284 -0
  52. agent_eval_flow/pipeline/assessment_bindings.py +26 -0
  53. agent_eval_flow/pipeline/assessment_preflight.py +105 -0
  54. agent_eval_flow/pipeline/bindings.py +22 -0
  55. agent_eval_flow/pipeline/preflight.py +72 -0
  56. agent_eval_flow/py.typed +0 -0
  57. agent_eval_flow/reporting/__init__.py +1 -0
  58. agent_eval_flow/reporting/assessment_html.py +53 -0
  59. agent_eval_flow/reporting/html.py +150 -0
  60. agent_eval_flow/reporting/templates/assessment_report.html.j2 +67 -0
  61. agent_eval_flow/reporting/templates/report.css +1 -0
  62. agent_eval_flow/reporting/templates/report.html.j2 +118 -0
  63. agent_eval_flow/results/__init__.py +1 -0
  64. agent_eval_flow/results/assessment_comparison.py +74 -0
  65. agent_eval_flow/results/assessment_query.py +74 -0
  66. agent_eval_flow/results/assessment_selection.py +160 -0
  67. agent_eval_flow/results/comparison.py +102 -0
  68. agent_eval_flow/results/query.py +159 -0
  69. agent_eval_flow/results/selection.py +193 -0
  70. agent_eval_flow/storage/__init__.py +1 -0
  71. agent_eval_flow/storage/artifacts.py +131 -0
  72. agent_eval_flow/storage/assessment_codec.py +107 -0
  73. agent_eval_flow/storage/assessment_manifests.py +38 -0
  74. agent_eval_flow/storage/codec.py +296 -0
  75. agent_eval_flow/storage/manifests.py +111 -0
  76. agent_eval_flow-0.5.1.dist-info/METADATA +197 -0
  77. agent_eval_flow-0.5.1.dist-info/RECORD +79 -0
  78. agent_eval_flow-0.5.1.dist-info/WHEEL +5 -0
  79. 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)