openshell-agent-runner 0.0.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.
- openshell_agent_runner/__init__.py +4 -0
- openshell_agent_runner/artifacts.py +77 -0
- openshell_agent_runner/cli.py +293 -0
- openshell_agent_runner/config.py +382 -0
- openshell_agent_runner/errors.py +20 -0
- openshell_agent_runner/harnesses/__init__.py +4 -0
- openshell_agent_runner/harnesses/pi/__init__.py +4 -0
- openshell_agent_runner/harnesses/pi/resources.py +91 -0
- openshell_agent_runner/harnesses/pi/runtime/extensions/submit-result.ts +66 -0
- openshell_agent_runner/harnesses/pi/runtime/image/Dockerfile +28 -0
- openshell_agent_runner/harnesses/pi/runtime/image/exec.sh +68 -0
- openshell_agent_runner/harnesses/resources.py +17 -0
- openshell_agent_runner/openshell.py +162 -0
- openshell_agent_runner/profile_init.py +104 -0
- openshell_agent_runner/profiles/__init__.py +4 -0
- openshell_agent_runner/profiles/reviewer/models.json +19 -0
- openshell_agent_runner/profiles/reviewer/policy.yaml +15 -0
- openshell_agent_runner/profiles/reviewer/profile.yaml +12 -0
- openshell_agent_runner/profiles/reviewer/prompt.md +5 -0
- openshell_agent_runner/profiles/reviewer/settings.json +5 -0
- openshell_agent_runner/runner.py +277 -0
- openshell_agent_runner-0.0.1.dist-info/METADATA +404 -0
- openshell_agent_runner-0.0.1.dist-info/RECORD +26 -0
- openshell_agent_runner-0.0.1.dist-info/WHEEL +4 -0
- openshell_agent_runner-0.0.1.dist-info/entry_points.txt +3 -0
- openshell_agent_runner-0.0.1.dist-info/licenses/LICENSE +203 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Validate and publish agent results without interpreting domain fields."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from jsonschema import Draft202012Validator
|
|
14
|
+
|
|
15
|
+
from openshell_agent_runner.errors import ArtifactError
|
|
16
|
+
|
|
17
|
+
ARTIFACT_PATH = "/sandbox/artifacts/result"
|
|
18
|
+
MAX_ARTIFACT_BYTES = 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_artifact(downloaded: Path, schema_path: Path | None = None) -> None:
|
|
22
|
+
try:
|
|
23
|
+
size = downloaded.stat().st_size
|
|
24
|
+
except OSError as error:
|
|
25
|
+
raise ArtifactError(f"required artifact is missing: {downloaded}") from error
|
|
26
|
+
if size == 0:
|
|
27
|
+
raise ArtifactError("agent result is empty")
|
|
28
|
+
if size > MAX_ARTIFACT_BYTES:
|
|
29
|
+
raise ArtifactError(
|
|
30
|
+
f"output exceeds maximum size ({size} > {MAX_ARTIFACT_BYTES} bytes)"
|
|
31
|
+
)
|
|
32
|
+
if schema_path is None:
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
result = json.loads(downloaded.read_text(encoding="utf-8"))
|
|
36
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
37
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
38
|
+
raise ArtifactError(f"result is not valid JSON: {error}") from error
|
|
39
|
+
errors = sorted(
|
|
40
|
+
Draft202012Validator(schema).iter_errors(result),
|
|
41
|
+
key=lambda error: tuple(str(part) for part in error.absolute_path),
|
|
42
|
+
)
|
|
43
|
+
if errors:
|
|
44
|
+
diagnostics = "; ".join(error.message for error in errors[:12])
|
|
45
|
+
raise ArtifactError(f"result failed output schema validation: {diagnostics}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def atomic_publish(source: Path, destination: Path) -> None:
|
|
49
|
+
temporary: Path | None = None
|
|
50
|
+
try:
|
|
51
|
+
if destination.is_symlink():
|
|
52
|
+
raise ArtifactError(
|
|
53
|
+
f"artifact destination must not be a symlink: {destination}"
|
|
54
|
+
)
|
|
55
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
57
|
+
prefix=f".{destination.name}.", dir=destination.parent
|
|
58
|
+
)
|
|
59
|
+
temporary = Path(temporary_name)
|
|
60
|
+
with os.fdopen(descriptor, "wb") as target, source.open("rb") as incoming:
|
|
61
|
+
while block := incoming.read(64 * 1024):
|
|
62
|
+
target.write(block)
|
|
63
|
+
target.flush()
|
|
64
|
+
os.fsync(target.fileno())
|
|
65
|
+
temporary.replace(destination)
|
|
66
|
+
except ArtifactError:
|
|
67
|
+
raise
|
|
68
|
+
except OSError as error:
|
|
69
|
+
if temporary is not None:
|
|
70
|
+
temporary.unlink(missing_ok=True)
|
|
71
|
+
raise ArtifactError(
|
|
72
|
+
f"cannot publish artifact to {destination}: {error}"
|
|
73
|
+
) from error
|
|
74
|
+
except Exception:
|
|
75
|
+
if temporary is not None:
|
|
76
|
+
temporary.unlink(missing_ok=True)
|
|
77
|
+
raise
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Typer command-line interface."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shlex
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, NoReturn
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from typer._click import Context
|
|
14
|
+
from typer.core import TyperCommand
|
|
15
|
+
|
|
16
|
+
from openshell_agent_runner.config import ResolvedProfile, load_profile, resolve_task
|
|
17
|
+
from openshell_agent_runner.errors import ArtifactError, ConfigurationError, OarError
|
|
18
|
+
from openshell_agent_runner.openshell import NativeTarget
|
|
19
|
+
from openshell_agent_runner.openshell import doctor as run_doctor
|
|
20
|
+
from openshell_agent_runner.profile_init import ThinkingLevel, initialize_profiles
|
|
21
|
+
from openshell_agent_runner.runner import RunRequest, render_dry_run, run_agent
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
help="Launch ephemeral agents for single tasks in OpenShell sandboxes.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
pretty_exceptions_enable=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProfileTaskHelpCommand(TyperCommand):
|
|
32
|
+
"""Show focused help when a profile task is selected."""
|
|
33
|
+
|
|
34
|
+
def parse_args(self, ctx: Context, args: list[str]) -> list[str]:
|
|
35
|
+
ctx.meta["oar_raw_args"] = list(args)
|
|
36
|
+
return super().parse_args(ctx, args)
|
|
37
|
+
|
|
38
|
+
def get_help(self, ctx: Context) -> str:
|
|
39
|
+
selection = _profile_task_selection(ctx.meta.get("oar_raw_args", []))
|
|
40
|
+
if selection is None:
|
|
41
|
+
return super().get_help(ctx)
|
|
42
|
+
profile_directory, task_id = selection
|
|
43
|
+
try:
|
|
44
|
+
resolved = resolve_task(profile_directory, task_id)
|
|
45
|
+
except OarError as error:
|
|
46
|
+
_fail(error)
|
|
47
|
+
return _render_task_help(profile_directory, resolved, task_id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def init(
|
|
52
|
+
destination: Annotated[
|
|
53
|
+
Path,
|
|
54
|
+
typer.Argument(
|
|
55
|
+
help="Directory that will contain the initialized profiles.",
|
|
56
|
+
metavar="PROFILE_ROOT",
|
|
57
|
+
),
|
|
58
|
+
],
|
|
59
|
+
model: Annotated[
|
|
60
|
+
str,
|
|
61
|
+
typer.Option("--model", help="Inference route model identifier."),
|
|
62
|
+
],
|
|
63
|
+
profile: Annotated[
|
|
64
|
+
list[str] | None,
|
|
65
|
+
typer.Option(
|
|
66
|
+
"--profile",
|
|
67
|
+
help="Packaged profile to initialize. Repeat to select several; omit for all.",
|
|
68
|
+
),
|
|
69
|
+
] = None,
|
|
70
|
+
thinking: Annotated[
|
|
71
|
+
ThinkingLevel,
|
|
72
|
+
typer.Option("--thinking", help="Pi thinking level."),
|
|
73
|
+
] = ThinkingLevel.HIGH,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Create editable profiles from resources packaged with OAR."""
|
|
76
|
+
try:
|
|
77
|
+
created = initialize_profiles(
|
|
78
|
+
destination,
|
|
79
|
+
profile or (),
|
|
80
|
+
model,
|
|
81
|
+
thinking,
|
|
82
|
+
)
|
|
83
|
+
except OarError as error:
|
|
84
|
+
_fail(error)
|
|
85
|
+
typer.echo("Created profiles:")
|
|
86
|
+
for path in created:
|
|
87
|
+
typer.echo(f" {path}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command()
|
|
91
|
+
def validate(
|
|
92
|
+
profile: Annotated[
|
|
93
|
+
Path,
|
|
94
|
+
typer.Argument(
|
|
95
|
+
help="Profile directory containing profile.yaml.",
|
|
96
|
+
metavar="PROFILE_DIRECTORY",
|
|
97
|
+
),
|
|
98
|
+
],
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Validate a profile and all referenced local resources."""
|
|
101
|
+
try:
|
|
102
|
+
resolved = load_profile(profile)
|
|
103
|
+
except OarError as error:
|
|
104
|
+
_fail(error)
|
|
105
|
+
typer.echo(
|
|
106
|
+
f"Valid profile: {resolved.profile.id} ({len(resolved.profile.tasks)} task(s))"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command(cls=ProfileTaskHelpCommand)
|
|
111
|
+
def run(
|
|
112
|
+
profile: Annotated[
|
|
113
|
+
Path,
|
|
114
|
+
typer.Argument(
|
|
115
|
+
help="Profile directory containing profile.yaml.",
|
|
116
|
+
metavar="PROFILE_DIRECTORY",
|
|
117
|
+
),
|
|
118
|
+
],
|
|
119
|
+
task: Annotated[str, typer.Option("--task", help="Task identifier to run.")],
|
|
120
|
+
output: Annotated[
|
|
121
|
+
Path, typer.Option("--output", help="Host path for the agent result.")
|
|
122
|
+
],
|
|
123
|
+
input_document: Annotated[
|
|
124
|
+
Path | None,
|
|
125
|
+
typer.Option("--input", help="Host document required by document tasks."),
|
|
126
|
+
] = None,
|
|
127
|
+
upload: Annotated[
|
|
128
|
+
list[str] | None,
|
|
129
|
+
typer.Option("--upload", help="Native SOURCE:DESTINATION upload mapping."),
|
|
130
|
+
] = None,
|
|
131
|
+
environment: Annotated[
|
|
132
|
+
list[str] | None,
|
|
133
|
+
typer.Option("--env", help="Non-secret KEY=VALUE sandbox environment."),
|
|
134
|
+
] = None,
|
|
135
|
+
gateway: Annotated[
|
|
136
|
+
str | None, typer.Option("--gateway", help="OpenShell gateway name.")
|
|
137
|
+
] = None,
|
|
138
|
+
workspace: Annotated[
|
|
139
|
+
str, typer.Option("--workspace", help="OpenShell workspace name.")
|
|
140
|
+
] = "default",
|
|
141
|
+
timeout_seconds: Annotated[
|
|
142
|
+
int,
|
|
143
|
+
typer.Option("--timeout-seconds", min=1, help="Maximum agent runtime."),
|
|
144
|
+
] = 1200,
|
|
145
|
+
keep_sandbox: Annotated[
|
|
146
|
+
bool,
|
|
147
|
+
typer.Option("--keep-sandbox", help="Retain the sandbox for debugging."),
|
|
148
|
+
] = False,
|
|
149
|
+
dry_run: Annotated[
|
|
150
|
+
bool,
|
|
151
|
+
typer.Option(
|
|
152
|
+
"--dry-run",
|
|
153
|
+
help="Print every command and host action without executing them.",
|
|
154
|
+
),
|
|
155
|
+
] = False,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Launch or preview an ephemeral agent for one profile task."""
|
|
158
|
+
request = RunRequest(
|
|
159
|
+
profile_directory=profile,
|
|
160
|
+
task_id=task,
|
|
161
|
+
output=output,
|
|
162
|
+
input_document=input_document,
|
|
163
|
+
uploads=upload or (),
|
|
164
|
+
environments=environment or (),
|
|
165
|
+
gateway=gateway,
|
|
166
|
+
workspace=workspace,
|
|
167
|
+
timeout_seconds=timeout_seconds,
|
|
168
|
+
keep_sandbox=keep_sandbox,
|
|
169
|
+
)
|
|
170
|
+
try:
|
|
171
|
+
if dry_run:
|
|
172
|
+
typer.echo(render_dry_run(request), nl=False)
|
|
173
|
+
return
|
|
174
|
+
run_agent(request)
|
|
175
|
+
except OarError as error:
|
|
176
|
+
_fail(error)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.command()
|
|
180
|
+
def doctor(
|
|
181
|
+
gateway: Annotated[
|
|
182
|
+
str | None, typer.Option("--gateway", help="OpenShell gateway name.")
|
|
183
|
+
] = None,
|
|
184
|
+
workspace: Annotated[
|
|
185
|
+
str, typer.Option("--workspace", help="OpenShell workspace name.")
|
|
186
|
+
] = "default",
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Check OpenShell readiness without changing its state."""
|
|
189
|
+
try:
|
|
190
|
+
checks = run_doctor(NativeTarget(gateway=gateway, workspace=workspace))
|
|
191
|
+
except OarError as error:
|
|
192
|
+
_fail(error)
|
|
193
|
+
typer.echo("\n\n".join(result for _, result in checks))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _fail(error: OarError) -> NoReturn:
|
|
197
|
+
typer.echo(f"oar: {error}", err=True)
|
|
198
|
+
if isinstance(error, ArtifactError):
|
|
199
|
+
raise typer.Exit(3)
|
|
200
|
+
if isinstance(error, ConfigurationError):
|
|
201
|
+
raise typer.Exit(2)
|
|
202
|
+
raise typer.Exit(1)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _profile_task_selection(args: list[str]) -> tuple[Path, str] | None:
|
|
206
|
+
if not args or args[0].startswith("-"):
|
|
207
|
+
return None
|
|
208
|
+
profile = Path(args[0])
|
|
209
|
+
for index, argument in enumerate(args[1:], start=1):
|
|
210
|
+
if argument == "--task" and index + 1 < len(args):
|
|
211
|
+
return profile, args[index + 1]
|
|
212
|
+
if argument.startswith("--task="):
|
|
213
|
+
return profile, argument.partition("=")[2]
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _render_task_help(
|
|
218
|
+
profile_directory: Path,
|
|
219
|
+
resolved: ResolvedProfile,
|
|
220
|
+
task_id: str,
|
|
221
|
+
) -> str:
|
|
222
|
+
profile = resolved.profile
|
|
223
|
+
task = profile.tasks[task_id]
|
|
224
|
+
description = task.description or profile.description
|
|
225
|
+
usage_lines = [
|
|
226
|
+
_help_heading("Usage:"),
|
|
227
|
+
_help_command(f" oar run {shlex.quote(str(profile_directory))} \\"),
|
|
228
|
+
_help_command(f" --task {shlex.quote(task_id)} \\"),
|
|
229
|
+
]
|
|
230
|
+
if task.required_input == "document":
|
|
231
|
+
usage_lines.append(_help_command(" --input DOCUMENT \\"))
|
|
232
|
+
usage_lines.append(_help_command(" --output OUTPUT"))
|
|
233
|
+
|
|
234
|
+
upload_lines = [_help_heading("Additional configured uploads:")]
|
|
235
|
+
if profile.sandbox.upload:
|
|
236
|
+
upload_lines.extend(f" {upload}" for upload in profile.sandbox.upload)
|
|
237
|
+
else:
|
|
238
|
+
upload_lines.append(" None.")
|
|
239
|
+
|
|
240
|
+
environment_lines = [_help_heading("Configured environment:")]
|
|
241
|
+
if profile.sandbox.env:
|
|
242
|
+
environment_lines.extend(f" {value}" for value in profile.sandbox.env)
|
|
243
|
+
else:
|
|
244
|
+
environment_lines.append(" None. Add values with --env KEY=VALUE.")
|
|
245
|
+
|
|
246
|
+
input_lines = _required_input_help(task.required_input)
|
|
247
|
+
|
|
248
|
+
output_description = (
|
|
249
|
+
f"JSON validated against {task.output_schema}."
|
|
250
|
+
if task.output_schema is not None
|
|
251
|
+
else "The agent's final response."
|
|
252
|
+
)
|
|
253
|
+
return "\n".join(
|
|
254
|
+
(
|
|
255
|
+
typer.style(f"{profile.id}:{task_id}", fg=typer.colors.CYAN, bold=True),
|
|
256
|
+
"",
|
|
257
|
+
description,
|
|
258
|
+
"",
|
|
259
|
+
*usage_lines,
|
|
260
|
+
"",
|
|
261
|
+
*input_lines,
|
|
262
|
+
"",
|
|
263
|
+
*upload_lines,
|
|
264
|
+
"",
|
|
265
|
+
*environment_lines,
|
|
266
|
+
"",
|
|
267
|
+
_help_heading("Output:"),
|
|
268
|
+
f" {output_description}",
|
|
269
|
+
"",
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _required_input_help(required_input: str | None) -> list[str]:
|
|
275
|
+
if required_input is None:
|
|
276
|
+
return [_help_heading("Required input:"), " None."]
|
|
277
|
+
return [
|
|
278
|
+
_help_heading("Required argument:"),
|
|
279
|
+
_help_command(" --input DOCUMENT"),
|
|
280
|
+
" Host document to review.",
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _help_heading(value: str) -> str:
|
|
285
|
+
return typer.style(value, fg=typer.colors.YELLOW, bold=True)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _help_command(value: str) -> str:
|
|
289
|
+
return typer.style(value, fg=typer.colors.GREEN)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
app()
|