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.
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """OpenShell Agent Runner."""
@@ -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()