feat: import governed sandbox commits and enforce native CLI limits
Some checks are pending
Governed runtime contract / contract (push) Waiting to run

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 14:08:10 +02:00
parent 1429db5ad4
commit c63caf5568
16 changed files with 1236 additions and 7 deletions

View file

@ -24,6 +24,7 @@ one aggregate `LLMResponse` at the end for interface compatibility.
from __future__ import annotations
import json
import re
import subprocess
import threading
from pathlib import Path
@ -32,6 +33,7 @@ from typing import Any, Callable
from llm_connect.claude_code import ClaudeCodeAdapter
from llm_connect.exceptions import LLMSubprocessError, LLMTimeoutError
from llm_connect.models import LLMResponse, RunConfig
from rein_aharness.native_limits import NativeLimitError, terminal_accounting, validate_limits
from rein_aharness.execution_cancel import (
ExecutionCancel,
@ -99,6 +101,8 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
self._workdir = workdir
self._on_tool_event = on_tool_event
self._cancel = cancel
self._native_config = (None, None)
self._native_cli_checked = False
if isinstance(tool_profile, ToolProfile):
self._profile = tool_profile
else:
@ -109,6 +113,10 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
return self._profile
def _build_command(self, config: RunConfig) -> list[str]:
budget = config.model_params.get("max_budget_usd")
turns = config.model_params.get("max_turns")
validate_limits(budget, turns)
self._native_config = (budget, turns)
cmd = [
self._cli_path,
"--print",
@ -119,6 +127,12 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
]
if self._on_tool_event is not None:
cmd += ["--output-format", "stream-json", "--include-hook-events", "--verbose"]
elif budget is not None or turns is not None:
cmd += ["--output-format", "json"]
if budget is not None:
cmd += ["--max-budget-usd", str(budget)]
if turns is not None:
cmd += ["--max-turns", str(turns)]
if self._model:
cmd.extend(["--model", self._model])
return cmd
@ -126,12 +140,23 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self._preflight_budget(config)
cmd = self._build_command(config)
if self._native_config != (None, None) and not self._native_cli_checked:
check = subprocess.run([self._cli_path, "--version"], capture_output=True, text=True, timeout=10, cwd=self._workdir)
version = re.match(r"(\d+)\.(\d+)\.(\d+)(?:\s|$)", check.stdout.strip())
if check.returncode or not version or tuple(map(int, version.groups())) < (2, 1, 217):
raise NativeLimitError("native limits require verified Claude Code 2.1.217 or newer")
self._native_cli_checked = True
timeout = config.timeout_seconds or self._config.timeout_seconds
if self._on_tool_event is not None:
response = self._execute_streaming(cmd, prompt, timeout)
else:
response = self._execute_blocking(cmd, prompt, timeout)
self._consume_budget(config, response)
try:
self._consume_budget(config, response)
except Exception as exc:
if self._native_config != (None, None):
raise NativeLimitError("controlled Claude run failed token accounting", cost_usd=response.metadata.get("cost_usd")) from exc
raise
return response
def _execute_blocking(self, cmd: list[str], prompt: str, timeout: int) -> LLMResponse:
@ -144,7 +169,19 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
cwd=self._workdir,
)
stdout, stderr = self._wait_for_process(proc, prompt, timeout)
usage, cost = {}, None
if self._native_config != (None, None):
try:
envelope = json.loads(stdout)
except (TypeError, ValueError) as exc:
raise NativeLimitError("controlled Claude run returned invalid terminal JSON") from exc
usage, cost = terminal_accounting(envelope, max_budget_usd=self._native_config[0], max_turns=self._native_config[1])
stdout = envelope.get("result", "")
if not isinstance(stdout, str):
raise NativeLimitError("controlled Claude run returned invalid result text", cost_usd=cost)
if proc.returncode != 0:
if self._native_config != (None, None):
raise NativeLimitError("controlled Claude CLI exited unsuccessfully", cost_usd=cost)
raise LLMSubprocessError(
f"claude CLI exited with code {proc.returncode}",
return_code=proc.returncode,
@ -153,9 +190,10 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
return LLMResponse(
content=stdout,
model=self._model or "claude-code-cli",
usage={},
usage=usage,
finish_reason="stop",
metadata={
"cost_usd": cost,
"provider": "claude-code-agentic",
"cli_path": self._cli_path,
"workdir": str(self._workdir),
@ -174,6 +212,7 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
)
text_parts: list[str] = []
tool_event_count = 0
terminal_results = []
def reader() -> None:
nonlocal tool_event_count
@ -186,6 +225,8 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
event = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(event, dict) and event.get("type") == "result":
terminal_results.append(event)
self._handle_stream_event(event, text_parts)
if _is_tool_event(event):
tool_event_count += 1
@ -200,7 +241,14 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
reader_thread.join(timeout=5)
stderr = proc.stderr.read() if proc.stderr else ""
usage, cost = {}, None
if self._native_config != (None, None):
if reader_thread.is_alive() or len(terminal_results) != 1:
raise NativeLimitError("controlled Claude stream requires one complete terminal result")
usage, cost = terminal_accounting(terminal_results[0], max_budget_usd=self._native_config[0], max_turns=self._native_config[1])
if returncode != 0:
if self._native_config != (None, None):
raise NativeLimitError("controlled Claude CLI exited unsuccessfully", cost_usd=cost)
raise LLMSubprocessError(
f"claude CLI exited with code {returncode}",
return_code=returncode,
@ -210,7 +258,7 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
return LLMResponse(
content="".join(text_parts),
model=self._model or "claude-code-cli",
usage={},
usage=usage,
finish_reason="stop",
metadata={
"provider": "claude-code-agentic",
@ -218,6 +266,7 @@ class AgenticClaudeCodeAdapter(ClaudeCodeAdapter):
"workdir": str(self._workdir),
"tool_profile": self._profile.name,
"tool_event_count": tool_event_count,
"cost_usd": cost,
},
)

View file

@ -544,6 +544,7 @@ def _process_profiled_run_active(
client.config,
report_to_hub=report_to_hub,
cancel=cancel,
transaction=active_tx,
)
safe_evidence = normalise_execution_evidence_for_close(
gateway_result["evidence"]

View file

@ -300,6 +300,7 @@ def _cmd_run(args: argparse.Namespace) -> int:
"tool_profile": result.tool_profile,
"budget_tokens": result.budget_tokens,
"tokens_spent": result.tokens_spent,
"cost_usd": result.cost_usd,
"execution_time_s": round(result.execution_time_s, 3),
"reason": result.reason,
"model": result.model,

View file

@ -51,6 +51,7 @@ _EVIDENCE_STRING_FIELDS = (
_EVIDENCE_NUMBER_FIELDS = (
"duration_s",
"tokens_spent",
"cost_usd",
"token_budget",
"tool_events_count",
)
@ -142,6 +143,7 @@ def execute_profiled_run(
request_factory: Callable[..., Any] | None = None,
gateway: Callable[[Any], Any] | None = None,
cancel: ExecutionCancel | None = None,
transaction: Any = None,
) -> dict[str, Any]:
"""Invoke Glas and return its complete JSON-compatible GatewayResult."""
guard = resolve_cancel(cancel)
@ -159,9 +161,14 @@ def execute_profiled_run(
request_factory = request_factory or ExecutionRequest
gateway = gateway or run_execution
transfer = None
if run.repository_grant is not None:
from rein_aharness.repository_artifact import RepositoryArtifactTransfer
transfer = RepositoryArtifactTransfer(transaction, run.repository_grant, cancel=guard)
try:
request = request_factory(**_request_kwargs(run, config, report_to_hub))
result = gateway(request)
result = gateway(request, artifact_capture=transfer.capture) if transfer else gateway(request)
raw = result.model_dump(mode="json") if hasattr(result, "model_dump") else result
except ExecutionCancelled:
raise
@ -178,4 +185,10 @@ def execute_profiled_run(
raise GlasExecutionError("Glas gateway returned an invalid GatewayResult")
if not isinstance(raw.get("evidence"), dict):
raise GlasExecutionError("Glas GatewayResult is missing execution evidence")
if transfer is not None and raw["ok"]:
evidence = raw["evidence"]
if evidence.get("session_cleanup") != "succeeded" or evidence.get("sandbox_destroy") != "succeeded":
raise GlasExecutionError("artifact import requires confirmed session cleanup and sandbox teardown")
imported = transfer.import_after_teardown(evidence.get("commit_sha"))
raw["artifact_import"] = imported
return raw

View file

@ -0,0 +1,90 @@
"""Claude CLI controls and bounded terminal accounting, not a spend admission grant.
Daily/total reservation and provider cap semantics remain the operating owner's
responsibility. A post-run cost check cannot undo an already charged request.
"""
from __future__ import annotations
import math
from typing import Any
def validate_limits(max_budget_usd: Any, max_turns: Any) -> None:
if max_budget_usd is not None and (
isinstance(max_budget_usd, bool)
or not isinstance(max_budget_usd, (int, float))
or not math.isfinite(max_budget_usd)
or max_budget_usd <= 0
):
raise ValueError("max_budget_usd must be a finite positive USD amount")
if max_turns is not None and (
isinstance(max_turns, bool) or not isinstance(max_turns, int) or max_turns <= 0
):
raise ValueError("max_turns must be a positive integer")
class NativeLimitError(RuntimeError):
def __init__(self, message: str, *, cost_usd: float | None = None) -> None:
super().__init__(message)
self.cost_usd = cost_usd
def terminal_accounting(
result: Any, *, max_budget_usd: float | None, max_turns: int | None
) -> tuple[dict, float]:
if not isinstance(result, dict) or result.get("type") != "result":
raise NativeLimitError(
"controlled Claude run did not return terminal accounting"
)
cost = result.get("total_cost_usd")
if (
isinstance(cost, bool)
or not isinstance(cost, (int, float))
or not math.isfinite(cost)
or cost < 0
):
raise NativeLimitError("controlled Claude run returned invalid cost accounting")
cost = float(cost)
if result.get("subtype") != "success" or result.get("is_error") is not False:
raise NativeLimitError(
"controlled Claude run did not complete successfully", cost_usd=cost
)
if max_budget_usd is not None and cost > max_budget_usd:
raise NativeLimitError(
"controlled Claude run exceeded its native USD limit", cost_usd=cost
)
turns = result.get("num_turns")
if (
isinstance(turns, bool)
or not isinstance(turns, int)
or turns < 0
or (max_turns is not None and turns > max_turns)
):
raise NativeLimitError(
"controlled Claude run returned invalid turn accounting", cost_usd=cost
)
usage = result.get("usage")
if not isinstance(usage, dict):
raise NativeLimitError(
"controlled Claude run returned no token accounting", cost_usd=cost
)
counts = {}
for name in (
"input_tokens",
"output_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
):
value = usage.get(name, 0 if name.startswith("cache_") else None)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise NativeLimitError(
"controlled Claude run returned invalid token accounting", cost_usd=cost
)
counts[name] = value
return {
"prompt_tokens": counts["input_tokens"]
+ counts["cache_creation_input_tokens"]
+ counts["cache_read_input_tokens"],
"completion_tokens": counts["output_tokens"],
"total_tokens": sum(counts.values()),
}, cost

View file

@ -0,0 +1,320 @@
"""Bounded sandbox Git return into an already-owned repository transaction.
Artifact bytes stay process-local and are discarded on any gateway/teardown
failure. They never enter GatewayResult, Hub, metrics or the close outbox.
The initial contract supports one local commit, matching the factory pilot.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import os
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from rein_aharness.execution_cancel import ExecutionCancel, resolve_cancel
from rein_aharness.repository_grant import RepositoryGrant
from rein_aharness.repository_transaction import (
RepositoryTransaction,
RepositoryTransactionError,
)
MAX_BUNDLE_BYTES = 128 * 1024
MAX_NEW_OBJECT_BYTES = 2 * 1024 * 1024
_OID = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
class RepositoryArtifactError(RepositoryTransactionError):
"""A sandbox result cannot be safely imported under this transaction."""
# Executed inside the selected sandbox by its owner, with no host path access.
# The owner transport separately caps output and wall time. No binary or source
# content is placed in command arguments or exception messages.
_EXPORT = r"""
import base64, json, pathlib, subprocess, sys, tempfile
repo, baseline, expected, limit = sys.argv[1:]
limit = int(limit)
def git(*args):
p = subprocess.run(["git", "-C", repo, "-c", "core.hooksPath=/dev/null", *args], capture_output=True, timeout=20)
if p.returncode: raise RuntimeError("sandbox artifact Git operation refused")
return p.stdout
if git("rev-parse", "HEAD").decode().strip() != expected:
raise RuntimeError("sandbox artifact head mismatch")
if git("status", "--porcelain=v1", "--untracked-files=all").strip():
raise RuntimeError("sandbox artifact checkout is not clean")
if git("rev-list", "--count", baseline + "..HEAD").strip() != b"1":
raise RuntimeError("sandbox artifact must contain one commit")
git("merge-base", "--is-ancestor", baseline, "HEAD")
with tempfile.TemporaryDirectory(prefix="rein-artifact-") as d:
path = pathlib.Path(d) / "return.bundle"
git("bundle", "create", str(path), baseline + "..HEAD")
if path.stat().st_size > limit: raise RuntimeError("sandbox artifact too large")
payload = path.read_bytes()
print(json.dumps({"version": "1", "baseline": baseline, "head": expected,
"bundle": base64.b64encode(payload).decode("ascii")}))
"""
def _git(repo: Path, *args: str) -> bytes:
# Do not permit inherited Git directory/object/config overrides to redirect
# the transfer. All transport is between owner-selected local paths.
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
env.update(
GIT_CONFIG_NOSYSTEM="1",
GIT_CONFIG_GLOBAL=os.devnull,
GIT_TERMINAL_PROMPT="0",
GIT_NO_REPLACE_OBJECTS="1",
)
try:
proc = subprocess.run(
[
"git",
"-C",
str(repo),
"-c",
"core.hooksPath=/dev/null",
"-c",
"core.fsmonitor=false",
"-c",
"protocol.allow=never",
"-c",
"protocol.file.allow=always",
*args,
],
env=env,
capture_output=True,
timeout=30,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RepositoryArtifactError(
"artifact Git operation unavailable or timed out"
) from exc
if proc.returncode:
raise RepositoryArtifactError("artifact Git validation or import refused")
return proc.stdout
class RepositoryArtifactTransfer:
"""Trusted worker-owned capture; source mutation waits for successful teardown."""
def __init__(
self,
transaction: RepositoryTransaction,
grant: RepositoryGrant,
*,
cancel: ExecutionCancel | None = None,
) -> None:
if (
not isinstance(transaction, RepositoryTransaction)
or not transaction.locked
or transaction.baseline is None
):
raise RepositoryArtifactError(
"artifact transfer requires the active repository transaction"
)
if grant.min_commits != 1 or grant.max_commits != 1 or grant.publish:
raise RepositoryArtifactError(
"artifact transfer v1 requires exactly one local commit"
)
self.transaction = transaction
self.grant = grant
self.cancel = resolve_cancel(cancel)
self._bundle: bytes | None = None
self._head: str | None = None
def _check(self) -> None:
if self.cancel is not None:
self.cancel.check()
self.transaction.assert_baseline_unchanged()
def capture(self, sandbox: Any, summary: Any) -> None:
"""Called by Glas after session cleanup, before sandbox destruction."""
from glas_harness.transport import transport_from_sandbox
self._check()
if self._bundle is not None:
raise RepositoryArtifactError("artifact capture was already completed")
head = summary.commit_sha
if not isinstance(head, str) or not _OID.fullmatch(head):
raise RepositoryArtifactError(
"sandbox did not report an exact commit identity"
)
transport = transport_from_sandbox(sandbox)
if transport.kind != "local_namespace":
raise RepositoryArtifactError(
"artifact transfer v1 requires bounded sandbox owner execution"
)
result = transport.run(
[
"python3",
"-c",
_EXPORT,
transport.workspace,
self.transaction.baseline.head,
head,
str(MAX_BUNDLE_BYTES),
],
timeout=30,
)
if result.returncode:
raise RepositoryArtifactError("sandbox artifact export refused")
if len(result.stdout) > (MAX_BUNDLE_BYTES * 4 // 3) + 1024:
raise RepositoryArtifactError("sandbox artifact exceeds transport limit")
try:
payload = json.loads(result.stdout)
if (
not isinstance(payload, dict)
or set(payload) != {"version", "baseline", "head", "bundle"}
or payload["version"] != "1"
or payload["baseline"] != self.transaction.baseline.head
or payload["head"] != head
):
raise ValueError("artifact envelope mismatch")
bundle = base64.b64decode(payload["bundle"], validate=True)
if not bundle or len(bundle) > MAX_BUNDLE_BYTES:
raise ValueError("artifact byte limit")
except (ValueError, TypeError, binascii.Error) as exc:
raise RepositoryArtifactError(
"sandbox artifact envelope is invalid"
) from exc
self._bundle, self._head = bundle, head
self._check()
def import_after_teardown(self, reported_head: str | None) -> dict[str, Any]:
"""Validate in a disposable checkout, then fast-forward under the live lease."""
self._check()
if self._bundle is None or self._head is None or reported_head != self._head:
raise RepositoryArtifactError(
"successful gateway did not return the captured artifact"
)
baseline = self.transaction.baseline
with tempfile.TemporaryDirectory(prefix="rein-artifact-verify-") as temporary:
root = Path(temporary)
bundle_path = root / "return.bundle"
bundle_path.write_bytes(self._bundle)
bundle_path.chmod(0o600)
staging = root / "checkout"
_git(
root,
"clone",
"--quiet",
"--no-local",
"--no-hardlinks",
"--no-checkout",
"--",
str(baseline.repo_root),
str(staging),
)
_git(staging, "checkout", "--quiet", "--detach", baseline.head)
with RepositoryTransaction(staging, state_dir=root / "state") as inspection:
_git(staging, "bundle", "verify", str(bundle_path))
advertised = (
_git(staging, "bundle", "list-heads", str(bundle_path))
.decode()
.strip()
)
if advertised != self._head + " HEAD":
raise RepositoryArtifactError(
"artifact must advertise only the expected HEAD"
)
_git(
staging,
"fetch",
"--quiet",
"--no-tags",
"--no-write-fetch-head",
str(bundle_path),
"HEAD",
)
if (
_git(
staging, "rev-list", "--count", f"{baseline.head}..{self._head}"
).strip()
!= b"1"
):
raise RepositoryArtifactError(
"artifact contains an unexpected commit count"
)
_git(staging, "merge-base", "--is-ancestor", baseline.head, self._head)
# Reject symlinks and gitlinks before checking out untrusted data.
changes = _git(
staging,
"diff",
"--raw",
"--no-renames",
"-z",
baseline.head,
self._head,
"--",
).split(b"\0")
for header in changes[::2]:
if header and header.split()[1] not in (
b"100644",
b"100755",
b"000000",
):
raise RepositoryArtifactError(
"artifact changes an unsupported file mode"
)
oids = _git(
staging,
"rev-list",
"--objects",
"--no-object-names",
f"{baseline.head}..{self._head}",
).splitlines()
if len(oids) > 1024:
raise RepositoryArtifactError(
"artifact contains too many new objects"
)
total = 0
for oid in oids:
total += int(_git(staging, "cat-file", "-s", oid.decode()).strip())
if total > MAX_NEW_OBJECT_BYTES:
raise RepositoryArtifactError(
"artifact expands beyond the accepted byte limit"
)
_git(
staging, "merge", "--ff-only", "--no-edit", "--no-stat", self._head
)
inspection.validate_acceptance(self.grant.acceptance_policy())
self._check()
_git(
baseline.repo_root,
"fetch",
"--quiet",
"--no-tags",
"--no-write-fetch-head",
str(bundle_path),
"HEAD",
)
self._check()
_git(
baseline.repo_root,
"merge",
"--ff-only",
"--no-edit",
"--no-stat",
self._head,
)
if self.cancel is not None:
self.cancel.check()
accepted = self.transaction.validate_acceptance(
self.grant.acceptance_policy()
)
evidence = {
"version": "1",
"sha256": hashlib.sha256(self._bundle).hexdigest(),
"bytes": len(self._bundle),
"head": accepted.head,
"imported": True,
}
self._bundle = None
return evidence

View file

@ -296,6 +296,15 @@ class RepositoryTransaction:
evidence["acceptance"] = self.acceptance.evidence()
return evidence
def assert_baseline_unchanged(self) -> None:
"""Refuse artifact import if the locked source no longer matches admission."""
if not self.locked or self.baseline is None:
raise RepositoryTransactionError("baseline verification requires an active transaction")
baseline = self.baseline
current = _capture_baseline(baseline.repo_root, baseline.git_common_dir, baseline.repo_id)
if current != baseline:
raise RepositoryAcceptanceError("baseline-changed", "source changed before artifact import")
def validate_acceptance(
self,
policy: RepositoryAcceptancePolicy,

View file

@ -30,6 +30,7 @@ from rein_aharness.repository_transaction import (
RepositoryTransactionError,
)
from rein_aharness.taskspec import TaskSpec
from rein_aharness.native_limits import NativeLimitError
PROMPT_TEMPLATE = """\
You are an unattended executor session (agent persona below, if any).
@ -63,6 +64,7 @@ class RunResult:
tool_profile: str = ""
budget_tokens: int | None = None
tokens_spent: int | None = None
cost_usd: float | None = None
execution_time_s: float = 0.0
# Real-time per-tool-call audit events, populated only when run_task is
# called with emit_tool_events=True. See adapter.py's module docstring
@ -233,11 +235,14 @@ def _execute_locked_task(
timeout_seconds=spec.timeout_seconds,
skip_if_exists=False,
budget_tracker=budget_tracker,
model_params={"max_budget_usd": spec.max_budget_usd, "max_turns": spec.max_turns},
)
started = time.monotonic()
cost_usd = None
try:
response = adapter.execute_prompt(prompt, config)
cost_usd = response.metadata.get("cost_usd")
session_output = response.content
resolved_model = response.model
session_ok = True
@ -248,6 +253,8 @@ def _execute_locked_task(
reason = f"execution cancelled ({exc.reason})"
resolved_model = model
except Exception as exc: # adapter / budget failures must still be reported
if isinstance(exc, NativeLimitError):
cost_usd = exc.cost_usd
session_output = ""
session_ok = False
reason = f"session failed: {exc}"
@ -273,6 +280,7 @@ def _execute_locked_task(
metric_metadata = {
"task_title": spec.title,
"cost_usd": cost_usd,
"tool_profile": profile.name,
"labels": list(spec.labels),
"completion_event_type": spec.completion_event_type,
@ -325,6 +333,7 @@ def _execute_locked_task(
tool_profile=profile.name,
budget_tokens=budget_tokens,
tokens_spent=tokens_spent,
cost_usd=cost_usd,
execution_time_s=execution_time_s,
tool_events=collected_events,
transaction=transaction_evidence,

View file

@ -11,6 +11,7 @@ import json
from dataclasses import dataclass, field
from pathlib import Path
from rein_aharness.native_limits import validate_limits
from rein_aharness.repository_grant import RepositoryGrant, RepositoryGrantError
@ -29,6 +30,14 @@ class TaskSpec:
completion_event_type: str = "executor_run"
timeout_seconds: int = 900
repository_grant: RepositoryGrant | None = None
max_budget_usd: float | None = None
max_turns: int | None = None
def __post_init__(self) -> None:
try:
validate_limits(self.max_budget_usd, self.max_turns)
except ValueError as exc:
raise TaskSpecError(str(exc)) from exc
@classmethod
def from_file(cls, path: str | Path) -> "TaskSpec":
@ -55,4 +64,6 @@ class TaskSpec:
completion_event_type=str(raw.get("completion_event_type", "executor_run")),
timeout_seconds=int(raw.get("timeout_seconds", 900)),
repository_grant=grant,
max_budget_usd=raw.get("max_budget_usd"),
max_turns=raw.get("max_turns"),
)