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,382 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Define, load, validate, and resolve agent profile configuration."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
from typing import Annotated, Any, Literal
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from jsonschema import Draft202012Validator, SchemaError
|
|
16
|
+
from pydantic import (
|
|
17
|
+
BaseModel,
|
|
18
|
+
ConfigDict,
|
|
19
|
+
Field,
|
|
20
|
+
ValidationError,
|
|
21
|
+
field_validator,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from openshell_agent_runner.errors import ConfigurationError
|
|
25
|
+
|
|
26
|
+
IDENTIFIER_PATTERN = r"^[a-z][a-z0-9-]{0,62}$"
|
|
27
|
+
RESOURCE_IDENTIFIER_PATTERN = r"^[a-z][a-z0-9_-]{0,62}$"
|
|
28
|
+
MODEL_IDENTIFIER_PATTERN = r"^[A-Za-z0-9._:/-]{1,256}$"
|
|
29
|
+
MODELS_FILENAME = "models.json"
|
|
30
|
+
PROFILE_FILENAME = "profile.yaml"
|
|
31
|
+
SETTINGS_FILENAME = "settings.json"
|
|
32
|
+
_PI_RUNTIME_SETTING_KEYS = {
|
|
33
|
+
"defaultProvider",
|
|
34
|
+
"defaultModel",
|
|
35
|
+
"defaultThinkingLevel",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StrictModel(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="forbid")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SandboxConfig(StrictModel):
|
|
44
|
+
policy: Path
|
|
45
|
+
upload: list[str] = Field(default_factory=list)
|
|
46
|
+
env: list[str] = Field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
@field_validator("upload")
|
|
49
|
+
@classmethod
|
|
50
|
+
def validate_uploads(cls, values: list[str]) -> list[str]:
|
|
51
|
+
validate_upload_mappings(values)
|
|
52
|
+
return values
|
|
53
|
+
|
|
54
|
+
@field_validator("env")
|
|
55
|
+
@classmethod
|
|
56
|
+
def validate_environment(cls, values: list[str]) -> list[str]:
|
|
57
|
+
validate_environment_assignments(values)
|
|
58
|
+
return values
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TaskConfig(StrictModel):
|
|
62
|
+
description: str | None = Field(default=None, min_length=1, max_length=1000)
|
|
63
|
+
required_input: Literal["document"] | None = None
|
|
64
|
+
prompt: Path
|
|
65
|
+
output_schema: Path | None = None
|
|
66
|
+
tools: list[Annotated[str, Field(pattern=RESOURCE_IDENTIFIER_PATTERN)]] = Field(
|
|
67
|
+
default_factory=list
|
|
68
|
+
)
|
|
69
|
+
skills: list[Path] = Field(default_factory=list)
|
|
70
|
+
extensions: list[Path] = Field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
@field_validator("tools", "skills", "extensions")
|
|
73
|
+
@classmethod
|
|
74
|
+
def require_unique_resources(cls, values: list[object]) -> list[object]:
|
|
75
|
+
if len(values) != len(set(values)):
|
|
76
|
+
raise ValueError("resource entries must be unique")
|
|
77
|
+
return values
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ProfileConfig(StrictModel):
|
|
81
|
+
id: Annotated[str, Field(pattern=IDENTIFIER_PATTERN)]
|
|
82
|
+
description: str = Field(min_length=1, max_length=1000)
|
|
83
|
+
sandbox: SandboxConfig
|
|
84
|
+
tasks: dict[Annotated[str, Field(pattern=IDENTIFIER_PATTERN)], TaskConfig]
|
|
85
|
+
|
|
86
|
+
@field_validator("tasks")
|
|
87
|
+
@classmethod
|
|
88
|
+
def require_tasks(cls, value: dict[str, TaskConfig]) -> dict[str, TaskConfig]:
|
|
89
|
+
if not value:
|
|
90
|
+
raise ValueError("at least one task is required")
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PiRuntimeSettings(StrictModel):
|
|
95
|
+
provider: Literal["openshell"]
|
|
96
|
+
model: Annotated[str, Field(pattern=MODEL_IDENTIFIER_PATTERN)]
|
|
97
|
+
thinking: Literal["off", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ResolvedProfile(StrictModel):
|
|
101
|
+
profile_path: Path
|
|
102
|
+
profile_dir: Path
|
|
103
|
+
profile: ProfileConfig
|
|
104
|
+
runtime: PiRuntimeSettings
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_profile(directory: Path) -> ResolvedProfile:
|
|
108
|
+
try:
|
|
109
|
+
profile_dir = directory.resolve(strict=True)
|
|
110
|
+
except OSError as error:
|
|
111
|
+
raise ConfigurationError(f"missing profile directory: {directory}") from error
|
|
112
|
+
if not profile_dir.is_dir():
|
|
113
|
+
raise ConfigurationError(
|
|
114
|
+
f"profile must be a directory containing {PROFILE_FILENAME}: {directory}"
|
|
115
|
+
)
|
|
116
|
+
candidate = profile_dir / PROFILE_FILENAME
|
|
117
|
+
try:
|
|
118
|
+
profile_path = candidate.resolve(strict=True)
|
|
119
|
+
except OSError as error:
|
|
120
|
+
raise ConfigurationError(
|
|
121
|
+
f"missing profile configuration: {candidate}"
|
|
122
|
+
) from error
|
|
123
|
+
if not profile_path.is_relative_to(profile_dir) or not profile_path.is_file():
|
|
124
|
+
raise ConfigurationError(
|
|
125
|
+
f"profile configuration must be a file inside {profile_dir}: {candidate}"
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
profile = ProfileConfig.model_validate(_load_yaml(profile_path))
|
|
129
|
+
except ValidationError as error:
|
|
130
|
+
raise ConfigurationError(f"invalid profile {profile_path}: {error}") from error
|
|
131
|
+
model_path = _inside(profile_dir, profile_dir / MODELS_FILENAME, "Pi models file")
|
|
132
|
+
settings_path = _inside(
|
|
133
|
+
profile_dir, profile_dir / SETTINGS_FILENAME, "Pi settings file"
|
|
134
|
+
)
|
|
135
|
+
model_id = _load_pi_model_id(model_path)
|
|
136
|
+
resolved = ResolvedProfile(
|
|
137
|
+
profile_path=profile_path,
|
|
138
|
+
profile_dir=profile_dir,
|
|
139
|
+
profile=profile,
|
|
140
|
+
runtime=_load_pi_runtime_settings(settings_path, model_id),
|
|
141
|
+
)
|
|
142
|
+
_validate_profile_resources(resolved)
|
|
143
|
+
return resolved
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def resolve_task(profile_directory: Path, task_id: str) -> ResolvedProfile:
|
|
147
|
+
resolved = load_profile(profile_directory)
|
|
148
|
+
if task_id not in resolved.profile.tasks:
|
|
149
|
+
raise ConfigurationError(
|
|
150
|
+
f"unknown task {task_id!r} for profile {resolved.profile.id!r}"
|
|
151
|
+
)
|
|
152
|
+
return resolved
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate_upload_mappings(values: Sequence[str]) -> tuple[str, ...]:
|
|
156
|
+
if len(values) != len(set(values)):
|
|
157
|
+
raise ValueError("duplicate upload mapping")
|
|
158
|
+
destinations: dict[str, str] = {}
|
|
159
|
+
for value in values:
|
|
160
|
+
source, separator, destination = value.rpartition(":")
|
|
161
|
+
if not separator or not source or not destination.startswith("/"):
|
|
162
|
+
raise ValueError("uploads must use SOURCE:/ABSOLUTE/DESTINATION")
|
|
163
|
+
path = PurePosixPath(destination)
|
|
164
|
+
if destination.startswith("//") or str(path) != destination:
|
|
165
|
+
raise ValueError("upload destinations must use canonical absolute paths")
|
|
166
|
+
if ".." in path.parts:
|
|
167
|
+
raise ValueError("upload destinations must not contain '..'")
|
|
168
|
+
for reserved in (
|
|
169
|
+
PurePosixPath("/sandbox/artifacts"),
|
|
170
|
+
PurePosixPath("/sandbox/oar-runtime"),
|
|
171
|
+
):
|
|
172
|
+
if path == reserved or path.is_relative_to(reserved):
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"upload destination is reserved for runner resources: "
|
|
175
|
+
f"{destination}"
|
|
176
|
+
)
|
|
177
|
+
normalized = str(path)
|
|
178
|
+
previous = destinations.get(normalized)
|
|
179
|
+
if previous is not None and previous != source:
|
|
180
|
+
raise ValueError(f"conflicting upload destination: {destination}")
|
|
181
|
+
destinations[normalized] = source
|
|
182
|
+
return tuple(values)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def validate_environment_assignments(values: Sequence[str]) -> tuple[str, ...]:
|
|
186
|
+
if len(values) != len(set(values)):
|
|
187
|
+
raise ValueError("duplicate environment assignment")
|
|
188
|
+
assignments: dict[str, str] = {}
|
|
189
|
+
for value in values:
|
|
190
|
+
key, separator, assigned = value.partition("=")
|
|
191
|
+
if not separator or not assigned:
|
|
192
|
+
raise ValueError("environment must use non-empty KEY=VALUE syntax")
|
|
193
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
194
|
+
raise ValueError(f"invalid OpenShell environment name: {key!r}")
|
|
195
|
+
if key.startswith("OPENSHELL_"):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"environment name uses reserved OPENSHELL_ prefix: {key!r}"
|
|
198
|
+
)
|
|
199
|
+
previous = assignments.get(key)
|
|
200
|
+
if previous is not None and previous != assigned:
|
|
201
|
+
raise ValueError(f"conflicting environment values for key {key!r}")
|
|
202
|
+
assignments[key] = assigned
|
|
203
|
+
return tuple(values)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _load_yaml(path: Path) -> Any:
|
|
207
|
+
try:
|
|
208
|
+
with path.open(encoding="utf-8") as stream:
|
|
209
|
+
return yaml.safe_load(stream)
|
|
210
|
+
except (OSError, UnicodeError) as error:
|
|
211
|
+
raise ConfigurationError(
|
|
212
|
+
f"cannot read configuration {path}: {error}"
|
|
213
|
+
) from error
|
|
214
|
+
except yaml.YAMLError as error:
|
|
215
|
+
raise ConfigurationError(f"invalid YAML in {path}: {error}") from error
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _load_pi_model_id(path: Path) -> str:
|
|
219
|
+
try:
|
|
220
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
221
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
222
|
+
raise ConfigurationError(f"invalid Pi models file {path}: {error}") from error
|
|
223
|
+
providers = document.get("providers") if isinstance(document, dict) else None
|
|
224
|
+
if not isinstance(providers, dict) or set(providers) != {"openshell"}:
|
|
225
|
+
raise ConfigurationError(
|
|
226
|
+
"Pi models file must contain exactly one provider named 'openshell'"
|
|
227
|
+
)
|
|
228
|
+
provider = providers["openshell"]
|
|
229
|
+
models = provider.get("models") if isinstance(provider, dict) else None
|
|
230
|
+
if not isinstance(models, list) or len(models) != 1:
|
|
231
|
+
raise ConfigurationError(
|
|
232
|
+
"Pi models file must contain exactly one model under 'openshell'"
|
|
233
|
+
)
|
|
234
|
+
model = models[0]
|
|
235
|
+
model_id = model.get("id") if isinstance(model, dict) else None
|
|
236
|
+
if not isinstance(model_id, str) or not re.fullmatch(
|
|
237
|
+
MODEL_IDENTIFIER_PATTERN, model_id
|
|
238
|
+
):
|
|
239
|
+
raise ConfigurationError("Pi model must have a valid string id")
|
|
240
|
+
return model_id
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _load_pi_runtime_settings(path: Path, model_id: str) -> PiRuntimeSettings:
|
|
244
|
+
try:
|
|
245
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
246
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
247
|
+
raise ConfigurationError(f"invalid Pi settings file {path}: {error}") from error
|
|
248
|
+
if not isinstance(document, dict):
|
|
249
|
+
raise ConfigurationError(f"Pi settings file must contain an object: {path}")
|
|
250
|
+
unexpected = set(document) - _PI_RUNTIME_SETTING_KEYS
|
|
251
|
+
missing = _PI_RUNTIME_SETTING_KEYS - set(document)
|
|
252
|
+
if unexpected or missing:
|
|
253
|
+
diagnostics = []
|
|
254
|
+
if missing:
|
|
255
|
+
diagnostics.append(f"missing {sorted(missing)}")
|
|
256
|
+
if unexpected:
|
|
257
|
+
diagnostics.append(f"unexpected {sorted(unexpected)}")
|
|
258
|
+
raise ConfigurationError(
|
|
259
|
+
f"Pi settings file must contain only runtime selection keys: "
|
|
260
|
+
f"{', '.join(diagnostics)}"
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
runtime = PiRuntimeSettings.model_validate(
|
|
264
|
+
{
|
|
265
|
+
"provider": document.get("defaultProvider"),
|
|
266
|
+
"model": document.get("defaultModel"),
|
|
267
|
+
"thinking": document.get("defaultThinkingLevel"),
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
except ValidationError as error:
|
|
271
|
+
raise ConfigurationError(
|
|
272
|
+
f"invalid Pi runtime settings in {path}: {error}"
|
|
273
|
+
) from error
|
|
274
|
+
if runtime.model != model_id:
|
|
275
|
+
raise ConfigurationError(
|
|
276
|
+
"Pi settings defaultModel must identify the model in models.json"
|
|
277
|
+
)
|
|
278
|
+
return runtime
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _inside(
|
|
282
|
+
owner: Path, candidate: Path, description: str, *, directory: bool = False
|
|
283
|
+
) -> Path:
|
|
284
|
+
try:
|
|
285
|
+
resolved = candidate.resolve(strict=True)
|
|
286
|
+
except OSError as error:
|
|
287
|
+
raise ConfigurationError(f"missing {description}: {candidate}") from error
|
|
288
|
+
owner_resolved = owner.resolve(strict=True)
|
|
289
|
+
if not resolved.is_relative_to(owner_resolved):
|
|
290
|
+
raise ConfigurationError(f"{description} escapes {owner_resolved}: {candidate}")
|
|
291
|
+
expected = "directory" if directory else "file"
|
|
292
|
+
if (directory and not resolved.is_dir()) or (
|
|
293
|
+
not directory and not resolved.is_file()
|
|
294
|
+
):
|
|
295
|
+
raise ConfigurationError(f"{description} must be a {expected}: {candidate}")
|
|
296
|
+
return resolved
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _validate_profile_resources(resolved: ResolvedProfile) -> None:
|
|
300
|
+
directory = resolved.profile_dir
|
|
301
|
+
_inside(directory, directory / resolved.profile.sandbox.policy, "sandbox policy")
|
|
302
|
+
for task_id, task in resolved.profile.tasks.items():
|
|
303
|
+
_inside(directory, directory / task.prompt, f"prompt for task {task_id}")
|
|
304
|
+
if task.output_schema is not None:
|
|
305
|
+
schema = _inside(
|
|
306
|
+
directory,
|
|
307
|
+
directory / task.output_schema,
|
|
308
|
+
f"output schema for task {task_id}",
|
|
309
|
+
)
|
|
310
|
+
_validate_output_schema(schema)
|
|
311
|
+
for skill in task.skills:
|
|
312
|
+
skill_directory = _inside(
|
|
313
|
+
directory,
|
|
314
|
+
directory / skill,
|
|
315
|
+
f"skill for task {task_id}",
|
|
316
|
+
directory=True,
|
|
317
|
+
)
|
|
318
|
+
_inside(
|
|
319
|
+
skill_directory,
|
|
320
|
+
skill_directory / "SKILL.md",
|
|
321
|
+
f"SKILL.md for task {task_id}",
|
|
322
|
+
)
|
|
323
|
+
for descendant in skill_directory.rglob("*"):
|
|
324
|
+
if descendant.is_symlink():
|
|
325
|
+
raise ConfigurationError(
|
|
326
|
+
f"skill for task {task_id} contains a symlink: {descendant}"
|
|
327
|
+
)
|
|
328
|
+
for extension in task.extensions:
|
|
329
|
+
_inside(directory, directory / extension, f"extension for task {task_id}")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _validate_output_schema(path: Path) -> None:
|
|
333
|
+
try:
|
|
334
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
335
|
+
Draft202012Validator.check_schema(document)
|
|
336
|
+
except (OSError, UnicodeError, json.JSONDecodeError, SchemaError) as error:
|
|
337
|
+
raise ConfigurationError(f"invalid output schema {path}: {error}") from error
|
|
338
|
+
_validate_schema_references(document, path)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _validate_schema_references(document: Any, path: Path) -> None:
|
|
342
|
+
if not isinstance(document, dict):
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
for key in {"pattern", "patternProperties"}:
|
|
346
|
+
if key in document:
|
|
347
|
+
raise ConfigurationError(
|
|
348
|
+
"output schemas do not support regular-expression keywords "
|
|
349
|
+
f"({key}) because host and sandbox engines use different dialects"
|
|
350
|
+
)
|
|
351
|
+
for key in {"$ref", "$dynamicRef", "$recursiveRef"}:
|
|
352
|
+
if key in document and (
|
|
353
|
+
not isinstance(document[key], str) or not document[key].startswith("#")
|
|
354
|
+
):
|
|
355
|
+
raise ConfigurationError(
|
|
356
|
+
f"output schema references must stay inside {path}: {document[key]!r}"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
for key in {"$defs", "definitions", "properties", "dependentSchemas"}:
|
|
360
|
+
value = document.get(key)
|
|
361
|
+
if isinstance(value, dict):
|
|
362
|
+
for schema in value.values():
|
|
363
|
+
_validate_schema_references(schema, path)
|
|
364
|
+
for key in {"allOf", "anyOf", "oneOf", "prefixItems"}:
|
|
365
|
+
value = document.get(key)
|
|
366
|
+
if isinstance(value, list):
|
|
367
|
+
for schema in value:
|
|
368
|
+
_validate_schema_references(schema, path)
|
|
369
|
+
for key in {
|
|
370
|
+
"additionalProperties",
|
|
371
|
+
"contains",
|
|
372
|
+
"contentSchema",
|
|
373
|
+
"else",
|
|
374
|
+
"if",
|
|
375
|
+
"items",
|
|
376
|
+
"not",
|
|
377
|
+
"propertyNames",
|
|
378
|
+
"then",
|
|
379
|
+
"unevaluatedItems",
|
|
380
|
+
"unevaluatedProperties",
|
|
381
|
+
}:
|
|
382
|
+
_validate_schema_references(document.get(key), path)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Package-specific errors with stable CLI exit classifications."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OarError(Exception):
|
|
8
|
+
"""Base expected runner error."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigurationError(OarError):
|
|
12
|
+
"""Invalid configuration or invocation (exit code 2)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExecutionError(OarError):
|
|
16
|
+
"""OpenShell or agent execution failure (exit code 1)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ArtifactError(OarError):
|
|
20
|
+
"""Missing or invalid required artifact (exit code 3)."""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Materialize the explicit native-upload runtime bundle for Pi."""
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from openshell_agent_runner.config import (
|
|
12
|
+
MODELS_FILENAME,
|
|
13
|
+
SETTINGS_FILENAME,
|
|
14
|
+
ResolvedProfile,
|
|
15
|
+
)
|
|
16
|
+
from openshell_agent_runner.harnesses.resources import PreparedResources
|
|
17
|
+
|
|
18
|
+
SANDBOX_RUNTIME_ROOT = "/sandbox/oar-runtime"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def image_directory() -> Path:
|
|
22
|
+
return Path(str(files("openshell_agent_runner.harnesses.pi") / "runtime" / "image"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def prepare_resources(resolved: ResolvedProfile, task_id: str) -> PreparedResources:
|
|
26
|
+
temporary = tempfile.TemporaryDirectory(prefix="oar-pi-")
|
|
27
|
+
runtime = Path(temporary.name) / "runtime"
|
|
28
|
+
(runtime / "skills").mkdir(parents=True, exist_ok=True)
|
|
29
|
+
(runtime / "extensions").mkdir(parents=True, exist_ok=True)
|
|
30
|
+
task = resolved.profile.tasks[task_id]
|
|
31
|
+
shutil.copy2(resolved.profile_dir / task.prompt, runtime / "prompt.md")
|
|
32
|
+
shutil.copy2(resolved.profile_dir / MODELS_FILENAME, runtime / MODELS_FILENAME)
|
|
33
|
+
shutil.copy2(resolved.profile_dir / SETTINGS_FILENAME, runtime / SETTINGS_FILENAME)
|
|
34
|
+
arguments = [
|
|
35
|
+
"--provider",
|
|
36
|
+
resolved.runtime.provider,
|
|
37
|
+
"--model",
|
|
38
|
+
resolved.runtime.model,
|
|
39
|
+
"--thinking",
|
|
40
|
+
resolved.runtime.thinking,
|
|
41
|
+
]
|
|
42
|
+
tools = list(task.tools)
|
|
43
|
+
if task.output_schema is not None:
|
|
44
|
+
shutil.copy2(
|
|
45
|
+
resolved.profile_dir / task.output_schema, runtime / "output.schema.json"
|
|
46
|
+
)
|
|
47
|
+
submit_result = Path(
|
|
48
|
+
str(
|
|
49
|
+
files("openshell_agent_runner.harnesses.pi")
|
|
50
|
+
/ "runtime"
|
|
51
|
+
/ "extensions"
|
|
52
|
+
/ "submit-result.ts"
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
shutil.copy2(submit_result, runtime / "extensions" / "oar-submit-result.ts")
|
|
56
|
+
tools.append("submit_result")
|
|
57
|
+
arguments.extend(
|
|
58
|
+
[
|
|
59
|
+
"--extension",
|
|
60
|
+
f"{SANDBOX_RUNTIME_ROOT}/extensions/oar-submit-result.ts",
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
arguments.extend(["--tools", ",".join(tools)] if tools else ["--no-tools"])
|
|
64
|
+
for index, skill in enumerate(task.skills):
|
|
65
|
+
target = runtime / "skills" / f"{index:02d}-{skill.name}"
|
|
66
|
+
shutil.copytree(resolved.profile_dir / skill, target)
|
|
67
|
+
arguments.extend(["--skill", f"{SANDBOX_RUNTIME_ROOT}/skills/{target.name}"])
|
|
68
|
+
for index, extension in enumerate(task.extensions):
|
|
69
|
+
target = runtime / "extensions" / f"{index:02d}-{extension.name}"
|
|
70
|
+
shutil.copy2(resolved.profile_dir / extension, target)
|
|
71
|
+
arguments.extend(
|
|
72
|
+
["--extension", f"{SANDBOX_RUNTIME_ROOT}/extensions/{target.name}"]
|
|
73
|
+
)
|
|
74
|
+
uploads = [
|
|
75
|
+
f"{runtime / 'prompt.md'}:{SANDBOX_RUNTIME_ROOT}/prompt.md",
|
|
76
|
+
f"{runtime / 'models.json'}:{SANDBOX_RUNTIME_ROOT}/models.json",
|
|
77
|
+
f"{runtime / 'settings.json'}:{SANDBOX_RUNTIME_ROOT}/settings.json",
|
|
78
|
+
]
|
|
79
|
+
if task.output_schema is not None:
|
|
80
|
+
uploads.append(
|
|
81
|
+
f"{runtime / 'output.schema.json'}:{SANDBOX_RUNTIME_ROOT}/output.schema.json"
|
|
82
|
+
)
|
|
83
|
+
uploads.extend(
|
|
84
|
+
f"{path}:{SANDBOX_RUNTIME_ROOT}/skills"
|
|
85
|
+
for path in sorted((runtime / "skills").iterdir())
|
|
86
|
+
)
|
|
87
|
+
uploads.extend(
|
|
88
|
+
f"{path}:{SANDBOX_RUNTIME_ROOT}/extensions/{path.name}"
|
|
89
|
+
for path in sorted((runtime / "extensions").iterdir())
|
|
90
|
+
)
|
|
91
|
+
return PreparedResources(temporary, tuple(uploads), tuple(arguments))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
|
|
10
|
+
const runtimeRoot = process.env.OAR_RUNTIME_ROOT || "/sandbox/oar-runtime";
|
|
11
|
+
const schema = JSON.parse(
|
|
12
|
+
readFileSync(`${runtimeRoot}/output.schema.json`, "utf8"),
|
|
13
|
+
);
|
|
14
|
+
// Match Python jsonschema's Draft 2020-12 behavior: extension keywords and
|
|
15
|
+
// formats remain annotations, while standard structural keywords are enforced.
|
|
16
|
+
const validate = new Ajv2020({
|
|
17
|
+
allErrors: true,
|
|
18
|
+
strict: false,
|
|
19
|
+
validateFormats: false,
|
|
20
|
+
}).compile(schema);
|
|
21
|
+
const parameters = Type.Object({ result: Type.Unsafe(schema) });
|
|
22
|
+
const outputDirectory = "/sandbox/artifacts";
|
|
23
|
+
const outputPath = `${outputDirectory}/result`;
|
|
24
|
+
|
|
25
|
+
const submitResult = defineTool({
|
|
26
|
+
name: "submit_result",
|
|
27
|
+
label: "Submit Result",
|
|
28
|
+
description: "Validate and save the final task result.",
|
|
29
|
+
promptSnippet: "Submit the final result using the configured output schema",
|
|
30
|
+
promptGuidelines: [
|
|
31
|
+
"Call submit_result only when the task is complete.",
|
|
32
|
+
"Correct every validation error and call submit_result again if it is rejected.",
|
|
33
|
+
"Do not return the result as assistant text.",
|
|
34
|
+
],
|
|
35
|
+
parameters,
|
|
36
|
+
async execute(_toolCallId, { result }) {
|
|
37
|
+
if (!validate(result)) {
|
|
38
|
+
const diagnostics = (validate.errors || [])
|
|
39
|
+
.slice(0, 12)
|
|
40
|
+
.map((error) => `${error.instancePath || "/"}: ${error.message || "invalid"}`)
|
|
41
|
+
.join("\n");
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "text" as const, text: `Result rejected:\n${diagnostics}` }],
|
|
44
|
+
details: { accepted: false, diagnostics },
|
|
45
|
+
isError: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
mkdirSync(outputDirectory, { recursive: true, mode: 0o700 });
|
|
50
|
+
const temporaryPath = `${outputPath}.tmp`;
|
|
51
|
+
writeFileSync(temporaryPath, `${JSON.stringify(result, null, 2)}\n`, {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
mode: 0o600,
|
|
54
|
+
});
|
|
55
|
+
renameSync(temporaryPath, outputPath);
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: "text" as const, text: "Result accepted." }],
|
|
58
|
+
details: { accepted: true, outputPath },
|
|
59
|
+
terminate: true,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export default function (pi: ExtensionAPI) {
|
|
65
|
+
pi.registerTool(submitResult);
|
|
66
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
FROM node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03
|
|
2
|
+
|
|
3
|
+
ARG PI_VERSION=0.84.2
|
|
4
|
+
ARG AJV_VERSION=8.20.0
|
|
5
|
+
ARG TYPEBOX_VERSION=1.3.16
|
|
6
|
+
|
|
7
|
+
ENV NODE_PATH=/usr/local/lib/node_modules
|
|
8
|
+
|
|
9
|
+
RUN apt-get update \
|
|
10
|
+
&& apt-get install --yes --no-install-recommends ca-certificates git iproute2 python3 ripgrep \
|
|
11
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
12
|
+
|
|
13
|
+
RUN npm install --global --ignore-scripts \
|
|
14
|
+
"@earendil-works/pi-coding-agent@${PI_VERSION}" \
|
|
15
|
+
"ajv@${AJV_VERSION}" \
|
|
16
|
+
"typebox@${TYPEBOX_VERSION}" \
|
|
17
|
+
&& npm cache clean --force >/dev/null 2>&1 \
|
|
18
|
+
&& test "$(pi --version)" = "${PI_VERSION}"
|
|
19
|
+
|
|
20
|
+
RUN mkdir -p /opt/oar/pi /sandbox/artifacts /sandbox/tmp /workspace \
|
|
21
|
+
&& chown -R node:node /sandbox /workspace
|
|
22
|
+
|
|
23
|
+
COPY exec.sh /opt/oar/pi/exec.sh
|
|
24
|
+
RUN chmod 0755 /opt/oar/pi/exec.sh \
|
|
25
|
+
&& chmod -R a+rX,a-w /opt/oar
|
|
26
|
+
|
|
27
|
+
WORKDIR /sandbox
|
|
28
|
+
USER node
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
umask 077
|
|
7
|
+
|
|
8
|
+
model_id=""
|
|
9
|
+
arguments=("$@")
|
|
10
|
+
for ((index = 0; index < ${#arguments[@]}; index++)); do
|
|
11
|
+
if [[ "${arguments[$index]}" == "--model" && $((index + 1)) -lt ${#arguments[@]} ]]; then
|
|
12
|
+
model_id="${arguments[$((index + 1))]}"
|
|
13
|
+
break
|
|
14
|
+
fi
|
|
15
|
+
done
|
|
16
|
+
if [[ ! "$model_id" =~ ^[A-Za-z0-9._:/-]{1,256}$ ]]; then
|
|
17
|
+
echo "Pi harness: --model is missing or invalid" >&2
|
|
18
|
+
exit 2
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
payload=${OAR_RUNTIME_ROOT:-/sandbox/oar-runtime}
|
|
22
|
+
for required in "$payload/prompt.md" "$payload/models.json" "$payload/settings.json"; do
|
|
23
|
+
if [[ ! -f "$required" ]]; then
|
|
24
|
+
echo "Pi harness: missing required file: $required" >&2
|
|
25
|
+
exit 2
|
|
26
|
+
fi
|
|
27
|
+
done
|
|
28
|
+
|
|
29
|
+
pi_home=/sandbox/pi-home
|
|
30
|
+
mkdir -p "$pi_home/.pi/agent" /sandbox/artifacts /sandbox/tmp
|
|
31
|
+
install -m 0600 "$payload/models.json" "$pi_home/.pi/agent/models.json"
|
|
32
|
+
install -m 0600 "$payload/settings.json" "$pi_home/.pi/agent/settings.json"
|
|
33
|
+
|
|
34
|
+
export HOME="$pi_home"
|
|
35
|
+
export TMPDIR=/sandbox/tmp
|
|
36
|
+
export PI_OFFLINE=1
|
|
37
|
+
export PI_SKIP_VERSION_CHECK=1
|
|
38
|
+
export PI_TELEMETRY=0
|
|
39
|
+
export OAR_MODEL_ID="$model_id"
|
|
40
|
+
|
|
41
|
+
agent_workdir=${REPOSITORY_ROOT:-/sandbox}
|
|
42
|
+
if [[ ! -d "$agent_workdir" ]]; then
|
|
43
|
+
echo "Pi harness: REPOSITORY_ROOT is not a directory: $agent_workdir" >&2
|
|
44
|
+
exit 2
|
|
45
|
+
fi
|
|
46
|
+
cd "$agent_workdir"
|
|
47
|
+
|
|
48
|
+
stdout_path=/sandbox/artifacts/result.stdout
|
|
49
|
+
result_path=/sandbox/artifacts/result
|
|
50
|
+
pi \
|
|
51
|
+
--print \
|
|
52
|
+
--no-session \
|
|
53
|
+
--no-extensions \
|
|
54
|
+
--no-skills \
|
|
55
|
+
--no-prompt-templates \
|
|
56
|
+
--no-themes \
|
|
57
|
+
--no-context-files \
|
|
58
|
+
--no-approve \
|
|
59
|
+
--offline \
|
|
60
|
+
"$@" \
|
|
61
|
+
<"$payload/prompt.md" \
|
|
62
|
+
>"$stdout_path"
|
|
63
|
+
|
|
64
|
+
if [[ -s "$result_path" ]]; then
|
|
65
|
+
rm -f "$stdout_path"
|
|
66
|
+
else
|
|
67
|
+
mv "$stdout_path" "$result_path"
|
|
68
|
+
fi
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Shared resources prepared by an agent harness."""
|
|
5
|
+
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PreparedResources:
|
|
12
|
+
temporary: tempfile.TemporaryDirectory[str]
|
|
13
|
+
uploads: tuple[str, ...]
|
|
14
|
+
arguments: tuple[str, ...]
|
|
15
|
+
|
|
16
|
+
def close(self) -> None:
|
|
17
|
+
self.temporary.cleanup()
|