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.
- openrat/__init__.py +54 -0
- openrat/__main__.py +5 -0
- openrat/api/__init__.py +27 -0
- openrat/api/openrat.py +109 -0
- openrat/api/runner.py +318 -0
- openrat/core/__init__.py +31 -0
- openrat/core/artifact.py +117 -0
- openrat/core/errors.py +60 -0
- openrat/core/experiment_spec.py +116 -0
- openrat/core/governance/__init__.py +24 -0
- openrat/core/governance/autonomy.py +32 -0
- openrat/core/governance/patch.py +36 -0
- openrat/core/protocols.py +75 -0
- openrat/core/session/__init__.py +5 -0
- openrat/core/session/session.py +269 -0
- openrat/executors/__init__.py +40 -0
- openrat/executors/base_executor.py +13 -0
- openrat/executors/docker_executor.py +195 -0
- openrat/executors/registry.py +26 -0
- openrat/model/__init__.py +17 -0
- openrat/model/adapters/base_adapter.py +21 -0
- openrat/model/adapters/claude_adapter.py +41 -0
- openrat/model/adapters/gemini_adapter.py +47 -0
- openrat/model/adapters/oai_adapter.py +87 -0
- openrat/model/agent_loop.py +46 -0
- openrat/model/factory.py +25 -0
- openrat/model/types.py +32 -0
- openrat/sandbox/__init__.py +6 -0
- openrat/sandbox/guardrails.py +23 -0
- openrat/tasks/__init__.py +3 -0
- openrat/tasks/dag/__init__.py +3 -0
- openrat/tasks/dag/dag.py +151 -0
- openrat/tasks/dag/task.py +32 -0
- openrat/tasks/plan/__init__.py +3 -0
- openrat/tasks/plan/plan.py +83 -0
- openrat/tools/__init__.py +29 -0
- openrat/tools/base.py +79 -0
- openrat/tools/executor.py +139 -0
- openrat/tools/file_inspector.py +99 -0
- openrat/tools/log_reader.py +73 -0
- openrat/tools/patch_proposal.py +58 -0
- openrat/tools/registry.py +66 -0
- openrat-0.1.0.dist-info/METADATA +295 -0
- openrat-0.1.0.dist-info/RECORD +50 -0
- openrat-0.1.0.dist-info/WHEEL +5 -0
- openrat-0.1.0.dist-info/entry_points.txt +2 -0
- openrat-0.1.0.dist-info/licenses/LICENSE +21 -0
- openrat-0.1.0.dist-info/top_level.txt +2 -0
- ui/__init__.py +3 -0
- 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
openrat/api/__init__.py
ADDED
|
@@ -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)
|
openrat/core/__init__.py
ADDED
|
@@ -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
|
openrat/core/artifact.py
ADDED
|
@@ -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
|
+
}
|