openrat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. openrat/__init__.py +54 -0
  2. openrat/__main__.py +5 -0
  3. openrat/api/__init__.py +27 -0
  4. openrat/api/openrat.py +109 -0
  5. openrat/api/runner.py +318 -0
  6. openrat/core/__init__.py +31 -0
  7. openrat/core/artifact.py +117 -0
  8. openrat/core/errors.py +60 -0
  9. openrat/core/experiment_spec.py +116 -0
  10. openrat/core/governance/__init__.py +24 -0
  11. openrat/core/governance/autonomy.py +32 -0
  12. openrat/core/governance/patch.py +36 -0
  13. openrat/core/protocols.py +75 -0
  14. openrat/core/session/__init__.py +5 -0
  15. openrat/core/session/session.py +269 -0
  16. openrat/executors/__init__.py +40 -0
  17. openrat/executors/base_executor.py +13 -0
  18. openrat/executors/docker_executor.py +195 -0
  19. openrat/executors/registry.py +26 -0
  20. openrat/model/__init__.py +17 -0
  21. openrat/model/adapters/base_adapter.py +21 -0
  22. openrat/model/adapters/claude_adapter.py +41 -0
  23. openrat/model/adapters/gemini_adapter.py +47 -0
  24. openrat/model/adapters/oai_adapter.py +87 -0
  25. openrat/model/agent_loop.py +46 -0
  26. openrat/model/factory.py +25 -0
  27. openrat/model/types.py +32 -0
  28. openrat/sandbox/__init__.py +6 -0
  29. openrat/sandbox/guardrails.py +23 -0
  30. openrat/tasks/__init__.py +3 -0
  31. openrat/tasks/dag/__init__.py +3 -0
  32. openrat/tasks/dag/dag.py +151 -0
  33. openrat/tasks/dag/task.py +32 -0
  34. openrat/tasks/plan/__init__.py +3 -0
  35. openrat/tasks/plan/plan.py +83 -0
  36. openrat/tools/__init__.py +29 -0
  37. openrat/tools/base.py +79 -0
  38. openrat/tools/executor.py +139 -0
  39. openrat/tools/file_inspector.py +99 -0
  40. openrat/tools/log_reader.py +73 -0
  41. openrat/tools/patch_proposal.py +58 -0
  42. openrat/tools/registry.py +66 -0
  43. openrat-0.1.0.dist-info/METADATA +295 -0
  44. openrat-0.1.0.dist-info/RECORD +50 -0
  45. openrat-0.1.0.dist-info/WHEEL +5 -0
  46. openrat-0.1.0.dist-info/entry_points.txt +2 -0
  47. openrat-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. openrat-0.1.0.dist-info/top_level.txt +2 -0
  49. ui/__init__.py +3 -0
  50. ui/cli.py +38 -0
openrat/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ from importlib import import_module
2
+
3
+ """OpenRat: Experiment execution framework with planning & autonomy.
4
+
5
+ Primary Public API:
6
+ Openrat: Framework workflow (session → spec → plan → artifact)
7
+
8
+ Data Types & Governance:
9
+ ExperimentSpec: Experiment intent definition
10
+ Session, AutonomyLevel: Execution authority & governance
11
+ Artifact: Execution results
12
+
13
+ Extension Points:
14
+ BaseTool: Tool implementation framework
15
+ ToolRegistry: Named tool registry
16
+
17
+ For workflow documentation, see docs/AGENTS.md.
18
+ For executor configuration, see docs/EXECUTOR_POLICY.md.
19
+ """
20
+
21
+ __all__ = [
22
+ "Openrat",
23
+ "BaseTool",
24
+ "Artifact",
25
+ "ExperimentSpec",
26
+ "Session",
27
+ "Message",
28
+ "ModelResponse",
29
+ "AutonomyLevel",
30
+ ]
31
+
32
+
33
+ _EXPORTS = {
34
+ "Openrat": ("openrat.api.openrat", "Openrat"),
35
+ "BaseTool": ("openrat.tools.base", "BaseTool"),
36
+ "Artifact": ("openrat.core.artifact", "Artifact"),
37
+ "ExperimentSpec": ("openrat.core.experiment_spec", "ExperimentSpec"),
38
+ "Session": ("openrat.core.session.session", "Session"),
39
+ "Message": ("openrat.model.types", "Message"),
40
+ "ModelResponse": ("openrat.model.types", "ModelResponse"),
41
+ "AutonomyLevel": ("openrat.core.governance.autonomy", "AutonomyLevel"),
42
+ }
43
+
44
+
45
+ def __getattr__(name):
46
+ if name not in _EXPORTS:
47
+ raise AttributeError(name)
48
+
49
+ module_name, symbol = _EXPORTS[name]
50
+ module = import_module(module_name)
51
+ value = getattr(module, symbol)
52
+ globals()[name] = value
53
+ return value
54
+
openrat/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from ui.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,27 @@
1
+ from importlib import import_module
2
+
3
+ """OpenRat API entry points.
4
+
5
+ Public API:
6
+ - Openrat: Primary framework facade
7
+
8
+ Internal runtime (not for external use):
9
+ - OpenRatAgent: Low-level runtime adapter
10
+ - run(): Direct execution helper
11
+ """
12
+
13
+ __all__ = ["Openrat"]
14
+
15
+ _EXPORTS = {
16
+ "Openrat": ("openrat.api.openrat", "Openrat"),
17
+ }
18
+
19
+
20
+ def __getattr__(name):
21
+ if name not in _EXPORTS:
22
+ raise AttributeError(name)
23
+ module_name, symbol = _EXPORTS[name]
24
+ module = import_module(module_name)
25
+ value = getattr(module, symbol)
26
+ globals()[name] = value
27
+ return value
openrat/api/openrat.py ADDED
@@ -0,0 +1,109 @@
1
+ from collections.abc import Iterable, Mapping
2
+ from typing import Any, TYPE_CHECKING
3
+
4
+ from openrat.core.artifact import Artifact
5
+ from openrat.core.governance.autonomy import AutonomyLevel
6
+ from openrat.core.experiment_spec import ExperimentSpec
7
+ from openrat.core.session.session import Session
8
+ from openrat.model.types import Message, ModelResponse
9
+ from openrat.core.protocols import ToolProtocol, ToolRegistryProtocol
10
+ from openrat.tasks.plan.plan import Plan
11
+
12
+ if TYPE_CHECKING:
13
+ from .runner import OpenRatAgent
14
+
15
+
16
+ class Openrat:
17
+ """Framework facade for experiment orchestration.
18
+
19
+ Orchestrates the workflow:
20
+ 1. create_session() — define autonomy and governance
21
+ 2. build_plan() — construct execution plan from spec
22
+ 3. execute_plan() — execute with policy approval
23
+
24
+ Also provides direct execution (app.run()) and LLM loops (app.chat())
25
+ for convenience, though the framework workflow is recommended.
26
+ """
27
+
28
+ def __init__(self, config: Mapping[str, Any] | None = None):
29
+ self._config = dict(config or {})
30
+ self._agent: "OpenRatAgent | None" = None
31
+
32
+ def _ensure_agent(self) -> "OpenRatAgent":
33
+ if self._agent is None:
34
+ from .runner import OpenRatAgent
35
+
36
+ self._agent = OpenRatAgent(self._config)
37
+ return self._agent
38
+
39
+ @property
40
+ def tool_registry(self) -> ToolRegistryProtocol | None:
41
+ """Low-level tool registry (internal extension point).
42
+
43
+ This property is provided for advanced use cases that register custom tools
44
+ for the LLM loop. It is not part of the planned session/spec/plan/artifact
45
+ workflow and should be considered internal — subject to change.
46
+
47
+ For most use cases, use the framework workflow instead:
48
+ plan = app.build_plan(spec, session)
49
+ artifact = app.execute_plan(plan)
50
+ """
51
+ return getattr(self._ensure_agent(), "tool_registry", None)
52
+
53
+ def run(
54
+ self,
55
+ path: str,
56
+ timeout: int | None = None,
57
+ isolate: bool = True,
58
+ memory: str = "512m",
59
+ cpus: str = "1.0",
60
+ ) -> Mapping[str, Any]:
61
+ """Direct compatibility path (non-planned execution)."""
62
+ return self._ensure_agent().run(
63
+ path,
64
+ timeout=timeout,
65
+ isolate=isolate,
66
+ memory=memory,
67
+ cpus=cpus,
68
+ )
69
+
70
+ def chat(self, messages: str | list[Message], max_turns: int = 10) -> ModelResponse:
71
+ """Direct compatibility path to the low-level LLM agent loop."""
72
+ return self._ensure_agent().chat(messages, max_turns=max_turns)
73
+
74
+ def create_session(
75
+ self,
76
+ *,
77
+ autonomy: AutonomyLevel,
78
+ patch_policy: str,
79
+ user_approvals: Iterable[str] | None = None,
80
+ ) -> Session:
81
+ return Session(
82
+ autonomy=autonomy,
83
+ patch_policy=patch_policy,
84
+ user_approvals=set(user_approvals or set()),
85
+ )
86
+
87
+ def spec_from_final_json(self, data: Mapping[str, Any]) -> ExperimentSpec:
88
+ return ExperimentSpec.from_final_json(data)
89
+
90
+ def build_plan(
91
+ self,
92
+ spec: ExperimentSpec,
93
+ session: Session,
94
+ tool_capabilities: Mapping[str, str] | None = None,
95
+ ) -> Plan:
96
+ return Plan.build(spec, session, tool_capabilities=tool_capabilities)
97
+
98
+ def execute_plan(self, plan: Plan, session: Session, tools: Mapping[str, ToolProtocol]) -> Artifact:
99
+ plan.assert_executable()
100
+ plan.dag.execute(tools=tools, session=session)
101
+ return Artifact.from_dag_execution(
102
+ dag=plan.dag,
103
+ plan=plan,
104
+ session=session,
105
+ logs=(),
106
+ metrics={},
107
+ diagnostics={"governance": session.governance_report()},
108
+ patches_applied=tuple(p.get("patch_id", "") for p in session.patches_applied),
109
+ )
openrat/api/runner.py ADDED
@@ -0,0 +1,318 @@
1
+ from pathlib import Path
2
+ from collections.abc import Mapping
3
+ from typing import Any
4
+ import shutil
5
+ import tempfile
6
+ import re
7
+
8
+ from openrat.core.governance.autonomy import AutonomyLevel
9
+ from openrat.core.session.session import Session
10
+ from openrat.executors import DockerExecutor
11
+ from openrat.core.errors import UserInputError, EnvironmentError, InternalError
12
+ from openrat.model.types import Message, ModelResponse
13
+ from openrat.core.protocols import ExecutorProtocol, ToolRegistryProtocol
14
+ from openrat.sandbox.guardrails import validate_command_guardrails
15
+
16
+
17
+ DEFAULT_TIMEOUT_SECONDS = 300
18
+ MAX_TIMEOUT_SECONDS = 3600
19
+ DEFAULT_MEMORY_LIMIT = "512m"
20
+ MAX_MEMORY_BYTES = 4 * 1024 * 1024 * 1024 # 4 GiB
21
+ DEFAULT_CPU_LIMIT = "1.0"
22
+ MAX_CPU_LIMIT = 4.0
23
+ _MEMORY_RE = re.compile(r"^(\d+)([mMgG])$")
24
+
25
+
26
+ def validate_experiment_path(path: str) -> Path:
27
+ """Validate and normalize an experiment file path.
28
+
29
+ Internal utility; not part of public API.
30
+
31
+ Raises:
32
+ EnvironmentError: If path is outside cwd or does not exist.
33
+ """
34
+ p = Path(path)
35
+ if not p.exists():
36
+ raise EnvironmentError(f"Experiment file not found: {path}")
37
+ p = p.resolve()
38
+ cwd = Path.cwd().resolve()
39
+ try:
40
+ p.relative_to(cwd)
41
+ except ValueError:
42
+ # restrict running experiments outside current working directory
43
+ # Invalid experiment path currently mapped to EnvironmentError. This could also be argued as UserInputError.
44
+ raise EnvironmentError("Experiment path must live inside the current working directory")
45
+ return p
46
+
47
+
48
+ # Backward-compatible alias (private name indicates legacy)
49
+ _validate_experiment_path = validate_experiment_path
50
+
51
+
52
+ def _choose_executor(preferred: str | None, docker_image: str) -> ExecutorProtocol:
53
+ if preferred is not None and preferred != "docker":
54
+ raise UserInputError(
55
+ f"unsupported executor '{preferred}'",
56
+ hint="Openrat requires executor='docker'.",
57
+ )
58
+
59
+ if not shutil.which("docker"):
60
+ raise EnvironmentError(
61
+ "docker executor requested but docker is not available",
62
+ hint="Install Docker to run Openrat experiments.",
63
+ )
64
+
65
+ return DockerExecutor(image=docker_image)
66
+
67
+
68
+ def _validate_managed_mount_path(path: Path, *, allowed_base: Path, label: str) -> str:
69
+ resolved = path.resolve()
70
+ base = allowed_base.resolve()
71
+ if not resolved.exists() or not resolved.is_dir():
72
+ raise InternalError(f"managed {label} must be an existing directory")
73
+ try:
74
+ resolved.relative_to(base)
75
+ except ValueError as exc:
76
+ raise InternalError(f"managed {label} escaped allowed execution base") from exc
77
+ if resolved.is_symlink():
78
+ raise InternalError(f"managed {label} must not be a symlink")
79
+ return str(resolved)
80
+
81
+
82
+ def _memory_to_bytes(value: str) -> int:
83
+ match = _MEMORY_RE.match(value.strip())
84
+ if not match:
85
+ raise UserInputError("memory must match '<number><m|g>', e.g. '512m' or '1g'")
86
+
87
+ amount = int(match.group(1))
88
+ unit = match.group(2).lower()
89
+ if amount <= 0:
90
+ raise UserInputError("memory must be greater than zero")
91
+
92
+ if unit == "m":
93
+ return amount * 1024 * 1024
94
+ return amount * 1024 * 1024 * 1024
95
+
96
+
97
+ def _normalize_timeout(timeout: int | None, *, allow_unbounded_limits: bool) -> int | None:
98
+ if timeout is None:
99
+ if allow_unbounded_limits:
100
+ return None
101
+ return DEFAULT_TIMEOUT_SECONDS
102
+
103
+ if not isinstance(timeout, int) or timeout <= 0:
104
+ raise UserInputError("timeout must be a positive integer")
105
+
106
+ if not allow_unbounded_limits and timeout > MAX_TIMEOUT_SECONDS:
107
+ raise UserInputError(f"timeout must be <= {MAX_TIMEOUT_SECONDS} seconds")
108
+
109
+ return timeout
110
+
111
+
112
+ def _normalize_limits(
113
+ *,
114
+ timeout: int | None,
115
+ memory: str,
116
+ cpus: str,
117
+ allow_unbounded_limits: bool,
118
+ ) -> tuple[int | None, str, str]:
119
+ normalized_timeout = _normalize_timeout(timeout, allow_unbounded_limits=allow_unbounded_limits)
120
+
121
+ memory_limit = str(memory or DEFAULT_MEMORY_LIMIT).strip().lower()
122
+ if memory_limit in {"none", "unbounded", "unlimited"}:
123
+ if not allow_unbounded_limits:
124
+ raise UserInputError("unbounded memory requires explicit allow_unbounded_limits opt-in")
125
+ else:
126
+ memory_bytes = _memory_to_bytes(memory_limit)
127
+ if memory_bytes > MAX_MEMORY_BYTES:
128
+ raise UserInputError("memory exceeds maximum allowed limit of 4g")
129
+
130
+ cpu_limit = str(cpus or DEFAULT_CPU_LIMIT).strip().lower()
131
+ if cpu_limit in {"none", "unbounded", "unlimited"}:
132
+ if not allow_unbounded_limits:
133
+ raise UserInputError("unbounded CPU requires explicit allow_unbounded_limits opt-in")
134
+ else:
135
+ try:
136
+ cpu_value = float(cpu_limit)
137
+ except ValueError as exc:
138
+ raise UserInputError("cpus must be a numeric string") from exc
139
+
140
+ if cpu_value <= 0:
141
+ raise UserInputError("cpus must be greater than zero")
142
+ if cpu_value > MAX_CPU_LIMIT:
143
+ raise UserInputError(f"cpus must be <= {MAX_CPU_LIMIT}")
144
+
145
+ return normalized_timeout, memory_limit, cpu_limit
146
+
147
+
148
+ def run(
149
+ path: str,
150
+ *,
151
+ executor: str | None = None,
152
+ timeout: int | None = None,
153
+ docker_image: str = "python:3.11",
154
+ isolate: bool = True,
155
+ memory: str = "512m",
156
+ cpus: str = "1.0",
157
+ allow_unbounded_limits: bool = False,
158
+ ) -> Mapping[str, Any]:
159
+ """Internal direct execution helper (not part of public API).
160
+
161
+ By default this will copy the experiment into a per-run ephemeral directory
162
+ and execute inside that directory to limit filesystem access. The runner will
163
+ create separate `code/` (read-only) and `outputs/` (writable) mounts for Docker.
164
+ """
165
+ p = validate_experiment_path(path)
166
+ exec_obj = _choose_executor(executor, docker_image)
167
+
168
+ timeout_limit, memory_limit, cpu_limit = _normalize_limits(
169
+ timeout=timeout,
170
+ memory=memory,
171
+ cpus=cpus,
172
+ allow_unbounded_limits=allow_unbounded_limits,
173
+ )
174
+
175
+ if isolate:
176
+ with tempfile.TemporaryDirectory(prefix="openrat-run-") as td:
177
+ td_path = Path(td)
178
+ code_dir = td_path / "code"
179
+ outputs_dir = td_path / "outputs"
180
+ code_dir.mkdir()
181
+ outputs_dir.mkdir()
182
+ # copy experiment into code subdir
183
+ dest = code_dir / p.name
184
+ shutil.copy2(p, dest)
185
+
186
+ command = ["python", f"/code/{dest.name}"]
187
+ validate_command_guardrails(command)
188
+
189
+ payload = {
190
+ # run using container-local paths
191
+ "command": command,
192
+ "cwd": str(outputs_dir),
193
+ "timeout": timeout_limit,
194
+ "code_dir": _validate_managed_mount_path(code_dir, allowed_base=td_path, label="code_dir"),
195
+ "outputs_dir": _validate_managed_mount_path(outputs_dir, allowed_base=td_path, label="outputs_dir"),
196
+ "limits": {"memory": memory_limit, "cpus": cpu_limit},
197
+ "allow_unbounded_limits": allow_unbounded_limits,
198
+ }
199
+ result = exec_obj.execute(payload)
200
+ return result
201
+
202
+ command = ["python", str(p)]
203
+ validate_command_guardrails(command)
204
+
205
+ payload = {
206
+ "command": command,
207
+ "cwd": str(p.parent),
208
+ "timeout": timeout_limit,
209
+ "limits": {"memory": memory_limit, "cpus": cpu_limit},
210
+ "allow_unbounded_limits": allow_unbounded_limits,
211
+ }
212
+
213
+ result = exec_obj.execute(payload)
214
+ return result
215
+
216
+
217
+ class OpenRatAgent:
218
+ """Internal runtime adapter (not part of public API).
219
+
220
+ Provides direct execution and LLM agent loop capabilities.
221
+ Internal use only; use Openrat facade for public workflows.
222
+
223
+ Configuration:
224
+ - Execution keys (``executor``, ``docker_image``) for direct paths
225
+ - Model keys (``provider``, ``api_key``, ``model_name``) for LLM loop
226
+ """
227
+
228
+ def __init__(self, config: Mapping[str, Any] | None = None):
229
+ self.config = dict(config or {})
230
+ self.executor = self.config.get("executor")
231
+ self.docker_image = self.config.get("docker_image", "python:3.11")
232
+ self.allow_unbounded_limits = bool(self.config.get("allow_unbounded_limits", False))
233
+
234
+ session_from_config = self.config.get("session")
235
+ if isinstance(session_from_config, Session):
236
+ self.session = session_from_config
237
+ else:
238
+ autonomy_raw = self.config.get("autonomy", AutonomyLevel.OBSERVE)
239
+ autonomy = autonomy_raw if isinstance(autonomy_raw, AutonomyLevel) else AutonomyLevel(int(autonomy_raw))
240
+ self.session = Session(
241
+ autonomy=autonomy,
242
+ patch_policy=str(self.config.get("patch_policy", "disabled")),
243
+ user_approvals=set(self.config.get("user_approvals") or set()),
244
+ )
245
+
246
+ # Build the LLM agent loop only when a model provider is configured.
247
+ self.agent_loop: Any = None
248
+ self.tool_registry: ToolRegistryProtocol | None = None
249
+ if self.config.get("provider"):
250
+ from openrat.model.factory import ModelFactory
251
+ from openrat.model.agent_loop import AgentLoop
252
+ from openrat.tools.registry import ToolRegistry
253
+
254
+ adapter = ModelFactory.create(self.config)
255
+ self.tool_registry = ToolRegistry(session=self.session)
256
+
257
+ # Register the execution runner as callable tool for the model.
258
+ _self = self
259
+
260
+ def run_experiment(arguments: Mapping[str, Any]) -> Mapping[str, Any]:
261
+ path = arguments.get("path")
262
+ if not path:
263
+ return {"status": "error", "reason": "path is required"}
264
+ return _self.run(
265
+ path,
266
+ timeout=arguments.get("timeout"),
267
+ isolate=arguments.get("isolate", True),
268
+ memory=arguments.get("memory", "512m"),
269
+ cpus=arguments.get("cpus", "1.0"),
270
+ )
271
+
272
+ run_experiment.capability = "observe"
273
+ run_experiment.__openrat_trusted__ = True
274
+
275
+ self.tool_registry.register(
276
+ "run_experiment",
277
+ run_experiment,
278
+ capability="observe",
279
+ trusted=True,
280
+ )
281
+ self.agent_loop = AgentLoop(adapter, tool_registry=self.tool_registry)
282
+
283
+ def run(self, path: str, timeout: int | None = None, isolate: bool = True, memory: str = "512m", cpus: str = "1.0") -> Mapping[str, Any]:
284
+ """Internal direct execution (not part of public API)."""
285
+ self.session.authorize(
286
+ "observe",
287
+ action="runner.run",
288
+ metadata={"path": path},
289
+ )
290
+ return run(
291
+ path,
292
+ executor=self.executor,
293
+ timeout=timeout,
294
+ docker_image=self.docker_image,
295
+ isolate=isolate,
296
+ memory=memory,
297
+ cpus=cpus,
298
+ allow_unbounded_limits=self.allow_unbounded_limits,
299
+ )
300
+
301
+ def chat(self, messages: str | list[Message], max_turns: int = 10) -> ModelResponse:
302
+ """Internal LLM agent loop (not part of public API).
303
+
304
+ ``messages`` can be:
305
+ - a plain string (converted to a single user Message), or
306
+ - a list of ``openrat.model.types.Message`` objects.
307
+
308
+ Requires ``provider`` (and usually ``api_key``, ``model_name``) in config.
309
+ """
310
+ if self.agent_loop is None:
311
+ raise UserInputError(
312
+ "No model configured. Pass 'provider', 'api_key', and 'model_name' "
313
+ "in the config dict to enable the LLM agent loop."
314
+ )
315
+ from openrat.model.types import Message as _Message
316
+ if isinstance(messages, str):
317
+ messages = [_Message(role="user", content=messages)]
318
+ return self.agent_loop.run(list(messages), max_turns=max_turns)
@@ -0,0 +1,31 @@
1
+ from importlib import import_module
2
+
3
+ """Core framework types (Session, Artifact, ExperimentSpec, AutonomyLevel).
4
+
5
+ These types represent:
6
+ - Artifact: Immutable execution results (value type)
7
+ - ExperimentSpec: Declarative experiment intent (immutable specification)
8
+ - Session: Execution authority and approval state (mutable during execution)
9
+ - AutonomyLevel: Governance levels for capability authorization
10
+
11
+ All are accessible via the main openrat package.
12
+ """
13
+
14
+ __all__ = ["Artifact", "ExperimentSpec", "Session", "AutonomyLevel"]
15
+
16
+ _EXPORTS = {
17
+ "Artifact": ("openrat.core.artifact", "Artifact"),
18
+ "ExperimentSpec": ("openrat.core.experiment_spec", "ExperimentSpec"),
19
+ "Session": ("openrat.core.session.session", "Session"),
20
+ "AutonomyLevel": ("openrat.core.governance.autonomy", "AutonomyLevel"),
21
+ }
22
+
23
+
24
+ def __getattr__(name):
25
+ if name not in _EXPORTS:
26
+ raise AttributeError(name)
27
+ module_name, symbol = _EXPORTS[name]
28
+ module = import_module(module_name)
29
+ value = getattr(module, symbol)
30
+ globals()[name] = value
31
+ return value
@@ -0,0 +1,117 @@
1
+ from dataclasses import dataclass, field
2
+ from collections.abc import Mapping, Sequence
3
+ from typing import TYPE_CHECKING, Any
4
+ from types import MappingProxyType
5
+ from datetime import datetime, timezone
6
+ from uuid import UUID, uuid4
7
+
8
+ if TYPE_CHECKING:
9
+ from openrat.core.session.session import Session
10
+ from openrat.tasks.dag.dag import DAG
11
+ from openrat.tasks.plan.plan import Plan
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Artifact:
16
+ """Immutable result of experiment execution.
17
+
18
+ Contains observations, evaluations, logs, and metadata from a completed run.
19
+ """
20
+ id: UUID
21
+ created_at: datetime
22
+
23
+ observations: Mapping[str, Any]
24
+ evaluations: Mapping[str, Any]
25
+ diagnostics: Mapping[str, Any]
26
+
27
+ logs: Sequence[str]
28
+ patches_applied: Sequence[str]
29
+
30
+ metadata: Mapping[str, Any] = field(default_factory=dict)
31
+
32
+ @classmethod
33
+ def create(
34
+ cls,
35
+ *,
36
+ observations: Mapping[str, Any],
37
+ evaluations: Mapping[str, Any],
38
+ diagnostics: Mapping[str, Any],
39
+ logs: Sequence[str],
40
+ patches_applied: Sequence[str],
41
+ metadata: Mapping[str, Any] | None = None,
42
+ ) -> "Artifact":
43
+ """Create an immutable execution artifact."""
44
+ return cls(
45
+ id=uuid4(),
46
+ created_at=datetime.now(timezone.utc),
47
+ observations=MappingProxyType(dict(observations)),
48
+ evaluations=MappingProxyType(dict(evaluations)),
49
+ diagnostics=MappingProxyType(dict(diagnostics)),
50
+ logs=tuple(logs),
51
+ patches_applied=tuple(patches_applied),
52
+ metadata=MappingProxyType(dict(metadata or {})),
53
+ )
54
+
55
+ @classmethod
56
+ def from_dag_execution(
57
+ cls,
58
+ *,
59
+ dag: "DAG",
60
+ plan: "Plan",
61
+ session: "Session",
62
+ logs: Sequence[str] = (),
63
+ metrics: Mapping[str, Any] | None = None,
64
+ diagnostics: Mapping[str, Any] | None = None,
65
+ patches_applied: Sequence[str] = (),
66
+ ) -> "Artifact":
67
+ observations = {
68
+ task_id: dict(record.outputs)
69
+ for task_id, record in dag.state.items()
70
+ if record.outputs
71
+ }
72
+ failed = [
73
+ task_id
74
+ for task_id, record in dag.state.items()
75
+ if getattr(record.state, "value", str(record.state)) == "failed"
76
+ ]
77
+
78
+ return cls.create(
79
+ observations=observations,
80
+ evaluations={
81
+ "status": "failed" if failed else "success",
82
+ "metrics": dict(metrics or {}),
83
+ },
84
+ diagnostics=dict(diagnostics or {}),
85
+ logs=tuple(logs),
86
+ patches_applied=tuple(patches_applied),
87
+ metadata={
88
+ "plan_id": str(plan.id),
89
+ "session_id": str(session.id),
90
+ "failed_tasks": failed,
91
+ },
92
+ )
93
+
94
+ def summarize(self) -> dict[str, Any]:
95
+ return {
96
+ "id": str(self.id),
97
+ "status": self.evaluations.get("status"),
98
+ "metrics": self.evaluations.get("metrics", {}),
99
+ "patches_applied": len(self.patches_applied),
100
+ }
101
+
102
+ def has_failures(self) -> bool:
103
+ return bool(self.diagnostics)
104
+
105
+ #Minimal serialization:
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ return {
109
+ "id": str(self.id),
110
+ "created_at": self.created_at.isoformat(),
111
+ "observations": dict(self.observations),
112
+ "evaluations": dict(self.evaluations),
113
+ "diagnostics": dict(self.diagnostics),
114
+ "logs": list(self.logs),
115
+ "patches_applied": list(self.patches_applied),
116
+ "metadata": dict(self.metadata),
117
+ }