introspection-harbor 0.1.0__tar.gz
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.
- introspection_harbor-0.1.0/.gitignore +2 -0
- introspection_harbor-0.1.0/CHANGELOG.md +8 -0
- introspection_harbor-0.1.0/PKG-INFO +31 -0
- introspection_harbor-0.1.0/README.md +22 -0
- introspection_harbor-0.1.0/pyproject.toml +15 -0
- introspection_harbor-0.1.0/src/introspection_harbor/__init__.py +3 -0
- introspection_harbor-0.1.0/src/introspection_harbor/agent.py +433 -0
- introspection_harbor-0.1.0/tests/test_agent.py +210 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-08-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **cli:** add local Recipe evaluations ([#203](https://github.com/introspection-org/introspection-cli/issues/203)) ([b14d97a](https://github.com/introspection-org/introspection-cli/commit/b14d97a13d5ebe3c805df6321ad4d5b89788a082))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: introspection-harbor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Harbor installed-agent adapter for Introspection Recipes
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: <3.14,>=3.12
|
|
7
|
+
Requires-Dist: harbor<0.21.0,>=0.20.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# `introspection-harbor`
|
|
11
|
+
|
|
12
|
+
Harbor installed-agent adapter for local Introspection Recipes. Install it
|
|
13
|
+
through `introspection setup --target harbor`, then run a task through:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
introspection eval run --runner harbor --path evals/refund-task
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Select an inherited Recipe agent variant by its declared YAML `name`:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
introspection eval run --runner harbor --path evals/refund-task --agent agent2
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For example, `agents/agent2.yaml` can declare `name: agent2` and `from: agent`,
|
|
26
|
+
then override only its model, tools, extensions, or other variant settings.
|
|
27
|
+
|
|
28
|
+
The CLI selects the Recipe and optional hydrated Pi session. The adapter copies
|
|
29
|
+
that snapshot into Harbor's environment, invokes `introspection local`, and
|
|
30
|
+
writes both native Pi events and `trajectory.json` (ATIF) under the agent log
|
|
31
|
+
directory.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# `introspection-harbor`
|
|
2
|
+
|
|
3
|
+
Harbor installed-agent adapter for local Introspection Recipes. Install it
|
|
4
|
+
through `introspection setup --target harbor`, then run a task through:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
introspection eval run --runner harbor --path evals/refund-task
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Select an inherited Recipe agent variant by its declared YAML `name`:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
introspection eval run --runner harbor --path evals/refund-task --agent agent2
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For example, `agents/agent2.yaml` can declare `name: agent2` and `from: agent`,
|
|
17
|
+
then override only its model, tools, extensions, or other variant settings.
|
|
18
|
+
|
|
19
|
+
The CLI selects the Recipe and optional hydrated Pi session. The adapter copies
|
|
20
|
+
that snapshot into Harbor's environment, invokes `introspection local`, and
|
|
21
|
+
writes both native Pi events and `trajectory.json` (ATIF) under the agent log
|
|
22
|
+
directory.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "introspection-harbor"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Harbor installed-agent adapter for Introspection Recipes"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.12,<3.14"
|
|
8
|
+
dependencies = ["harbor>=0.20.0,<0.21.0"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/introspection_harbor"]
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, override
|
|
11
|
+
|
|
12
|
+
from harbor.agents.installed.base import BaseInstalledAgent, with_prompt_template
|
|
13
|
+
from harbor.agents.installed.node_install import nvm_node_install_snippet
|
|
14
|
+
from harbor.environments.base import BaseEnvironment
|
|
15
|
+
from harbor.models.agent.context import AgentContext
|
|
16
|
+
from harbor.models.trajectories import (
|
|
17
|
+
Agent,
|
|
18
|
+
FinalMetrics,
|
|
19
|
+
Metrics,
|
|
20
|
+
Observation,
|
|
21
|
+
ObservationResult,
|
|
22
|
+
Step,
|
|
23
|
+
ToolCall,
|
|
24
|
+
Trajectory,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_RUN_SPEC_ENV = "INTROSPECTION_EVAL_RUN_SPEC"
|
|
28
|
+
_REMOTE_SOURCE_DIR = "/tmp/introspection-recipe-source"
|
|
29
|
+
_REMOTE_SESSION_PATH = "/tmp/introspection-session.jsonl"
|
|
30
|
+
_NODE_MAJOR = 24
|
|
31
|
+
_NODE_VERSION_CHECK = (
|
|
32
|
+
"node -e 'process.exit(Number(process.versions.node.split(\".\")[0]) >= "
|
|
33
|
+
f"{_NODE_MAJOR} ? 0 : 1)'"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_EXCLUDED_DIRECTORY_NAMES = {
|
|
37
|
+
".git",
|
|
38
|
+
".hg",
|
|
39
|
+
".svn",
|
|
40
|
+
".aws",
|
|
41
|
+
".ssh",
|
|
42
|
+
"node_modules",
|
|
43
|
+
"__pycache__",
|
|
44
|
+
".pytest_cache",
|
|
45
|
+
".mypy_cache",
|
|
46
|
+
".ruff_cache",
|
|
47
|
+
}
|
|
48
|
+
_EXCLUDED_EVAL_DIRECTORIES = {
|
|
49
|
+
".evalite",
|
|
50
|
+
"eval-runs",
|
|
51
|
+
"eval-artifacts",
|
|
52
|
+
}
|
|
53
|
+
_EXCLUDED_SECRET_FILES = {
|
|
54
|
+
".npmrc",
|
|
55
|
+
".pypirc",
|
|
56
|
+
"credentials.json",
|
|
57
|
+
"service-account.json",
|
|
58
|
+
"service_account.json",
|
|
59
|
+
}
|
|
60
|
+
_SECRET_SUFFIXES = {".key", ".pem", ".p12", ".pfx"}
|
|
61
|
+
_SAFE_ENV_SUFFIXES = {".example", ".sample", ".template"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _adapter_version() -> str:
|
|
65
|
+
try:
|
|
66
|
+
return version("introspection-harbor")
|
|
67
|
+
except PackageNotFoundError:
|
|
68
|
+
return "0.1.0"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_ADAPTER_VERSION = _adapter_version()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _node_prerequisite_command() -> str:
|
|
75
|
+
"""Prepare Node installation without assuming a Debian task image."""
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
"set -eu; "
|
|
79
|
+
f"if {_NODE_VERSION_CHECK} 2>/dev/null; then exit 0; fi; "
|
|
80
|
+
"if command -v apk >/dev/null 2>&1; then "
|
|
81
|
+
"apk add --no-cache nodejs npm; "
|
|
82
|
+
f"{_NODE_VERSION_CHECK} || {{ "
|
|
83
|
+
f"echo 'The Alpine package repository must provide Node {_NODE_MAJOR} or newer' >&2; "
|
|
84
|
+
"exit 1; }; "
|
|
85
|
+
"elif command -v curl >/dev/null 2>&1; then :; "
|
|
86
|
+
"elif command -v apt-get >/dev/null 2>&1; then "
|
|
87
|
+
"apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y curl; "
|
|
88
|
+
"elif command -v dnf >/dev/null 2>&1; then dnf install -y curl; "
|
|
89
|
+
"elif command -v microdnf >/dev/null 2>&1; then microdnf install -y curl; "
|
|
90
|
+
"elif command -v yum >/dev/null 2>&1; then yum install -y curl; "
|
|
91
|
+
"elif command -v zypper >/dev/null 2>&1; then zypper --non-interactive install curl; "
|
|
92
|
+
"else echo 'Node setup requires curl or a supported system package manager' >&2; "
|
|
93
|
+
"exit 1; fi"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class IntrospectionRecipeAgent(BaseInstalledAgent):
|
|
98
|
+
"""Run the exact Recipe selected by ``introspection eval run``."""
|
|
99
|
+
|
|
100
|
+
SUPPORTS_ATIF = True
|
|
101
|
+
_OUTPUT_FILENAME = "pi.jsonl"
|
|
102
|
+
|
|
103
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
104
|
+
super().__init__(*args, **kwargs)
|
|
105
|
+
spec_path = os.environ.get(_RUN_SPEC_ENV)
|
|
106
|
+
if not spec_path:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"introspection-recipe must be launched by `introspection eval run --runner harbor`"
|
|
109
|
+
)
|
|
110
|
+
self._spec = json.loads(Path(spec_path).read_text())
|
|
111
|
+
if self._spec.get("schema_version") != 1:
|
|
112
|
+
raise ValueError("unsupported Introspection evaluation run specification")
|
|
113
|
+
candidate = self._spec.get("candidate") or {}
|
|
114
|
+
workspace_path = Path(candidate.get("workspace_path", ""))
|
|
115
|
+
recipe_path = Path(candidate.get("recipe_path", ""))
|
|
116
|
+
if not workspace_path.is_dir():
|
|
117
|
+
raise ValueError(f"Recipe workspace does not exist: {workspace_path}")
|
|
118
|
+
if not recipe_path.is_dir():
|
|
119
|
+
raise ValueError(f"Recipe source does not exist: {recipe_path}")
|
|
120
|
+
try:
|
|
121
|
+
self._recipe_relative_path = recipe_path.relative_to(workspace_path)
|
|
122
|
+
except ValueError as error:
|
|
123
|
+
raise ValueError("Recipe source must be inside its workspace") from error
|
|
124
|
+
self._workspace_path = workspace_path
|
|
125
|
+
self._recipe_path = recipe_path
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
@override
|
|
129
|
+
def name() -> str:
|
|
130
|
+
return "introspection-recipe"
|
|
131
|
+
|
|
132
|
+
@override
|
|
133
|
+
def version(self) -> str | None:
|
|
134
|
+
return _ADAPTER_VERSION
|
|
135
|
+
|
|
136
|
+
@override
|
|
137
|
+
async def install(self, environment: BaseEnvironment) -> None:
|
|
138
|
+
candidate = self._spec["candidate"]
|
|
139
|
+
await self.exec_as_root(
|
|
140
|
+
environment,
|
|
141
|
+
command=f"rm -rf {shlex.quote(_REMOTE_SOURCE_DIR)}",
|
|
142
|
+
)
|
|
143
|
+
with tempfile.TemporaryDirectory(prefix="introspection-recipe-source-") as temp_dir:
|
|
144
|
+
staged = Path(temp_dir) / "workspace"
|
|
145
|
+
_materialize_workspace(self._workspace_path, staged)
|
|
146
|
+
await environment.upload_dir(staged, _REMOTE_SOURCE_DIR)
|
|
147
|
+
await self.exec_as_root(
|
|
148
|
+
environment,
|
|
149
|
+
command=f"chmod -R a+rX {shlex.quote(_REMOTE_SOURCE_DIR)}",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
session_path = (self._spec.get("input") or {}).get("pi_session_path")
|
|
153
|
+
if session_path:
|
|
154
|
+
await environment.upload_file(Path(session_path), _REMOTE_SESSION_PATH)
|
|
155
|
+
|
|
156
|
+
await self.exec_as_root(environment, command=_node_prerequisite_command())
|
|
157
|
+
cli_version = shlex.quote(candidate["cli_version"])
|
|
158
|
+
source = shlex.quote(_REMOTE_SOURCE_DIR)
|
|
159
|
+
node_install = nvm_node_install_snippet(node_major=_NODE_MAJOR)
|
|
160
|
+
await self.exec_as_agent(
|
|
161
|
+
environment,
|
|
162
|
+
command=(
|
|
163
|
+
"set -euo pipefail; "
|
|
164
|
+
f"if {_NODE_VERSION_CHECK}; then npm -v; else {node_install}; fi && "
|
|
165
|
+
f"npm install -g @introspection-ai/cli@{cli_version} && "
|
|
166
|
+
f"if [ -f {source}/pnpm-lock.yaml ]; then "
|
|
167
|
+
f"corepack pnpm --dir {source} install --frozen-lockfile; "
|
|
168
|
+
f"elif [ -f {source}/yarn.lock ]; then "
|
|
169
|
+
f"corepack yarn --cwd {source} install --immutable; "
|
|
170
|
+
f"elif [ -f {source}/bun.lock ] || [ -f {source}/bun.lockb ]; then "
|
|
171
|
+
f"npm install -g bun && bun install --cwd {source} --frozen-lockfile; "
|
|
172
|
+
f"elif [ -f {source}/package-lock.json ]; then npm --prefix {source} ci; "
|
|
173
|
+
f"else npm --prefix {source} install; fi && "
|
|
174
|
+
"introspection setup --yes"
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@override
|
|
179
|
+
@with_prompt_template
|
|
180
|
+
async def run(
|
|
181
|
+
self,
|
|
182
|
+
instruction: str,
|
|
183
|
+
environment: BaseEnvironment,
|
|
184
|
+
context: AgentContext,
|
|
185
|
+
) -> None:
|
|
186
|
+
candidate = self._spec["candidate"]
|
|
187
|
+
prompt = _selected_prompt(self._spec, instruction)
|
|
188
|
+
args = [
|
|
189
|
+
"introspection",
|
|
190
|
+
"local",
|
|
191
|
+
"--work-dir",
|
|
192
|
+
_REMOTE_SOURCE_DIR,
|
|
193
|
+
"--runtime",
|
|
194
|
+
candidate["runtime"],
|
|
195
|
+
]
|
|
196
|
+
if candidate.get("agent"):
|
|
197
|
+
args.extend(["--agent", candidate["agent"]])
|
|
198
|
+
args.extend(["--print", prompt, "--mode", "json"])
|
|
199
|
+
pi_args: list[str] = []
|
|
200
|
+
if (self._spec.get("input") or {}).get("pi_session_path"):
|
|
201
|
+
pi_args.extend(["--session", _REMOTE_SESSION_PATH])
|
|
202
|
+
model_name = candidate.get("model") or self.model_name
|
|
203
|
+
if model_name:
|
|
204
|
+
provider, model = model_name.split("/", 1)
|
|
205
|
+
pi_args.extend(["--provider", provider, "--model", model])
|
|
206
|
+
if pi_args:
|
|
207
|
+
args.extend(["--", *pi_args])
|
|
208
|
+
|
|
209
|
+
env = _model_environment(model_name)
|
|
210
|
+
await self.exec_as_agent(
|
|
211
|
+
environment,
|
|
212
|
+
command=_recipe_run_command(args, self._OUTPUT_FILENAME),
|
|
213
|
+
env=env,
|
|
214
|
+
cwd="/app",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@override
|
|
218
|
+
def populate_context_post_run(self, context: AgentContext) -> None:
|
|
219
|
+
output_path = self.logs_dir / self._OUTPUT_FILENAME
|
|
220
|
+
if not output_path.is_file():
|
|
221
|
+
return
|
|
222
|
+
trajectory = _pi_events_to_atif(
|
|
223
|
+
output_path,
|
|
224
|
+
model_name=(self._spec.get("candidate") or {}).get("model") or self.model_name,
|
|
225
|
+
)
|
|
226
|
+
(self.logs_dir / "trajectory.json").write_text(
|
|
227
|
+
json.dumps(trajectory.to_json_dict(), indent=2)
|
|
228
|
+
)
|
|
229
|
+
if trajectory.final_metrics:
|
|
230
|
+
context.n_input_tokens = trajectory.final_metrics.total_prompt_tokens
|
|
231
|
+
context.n_output_tokens = trajectory.final_metrics.total_completion_tokens
|
|
232
|
+
context.n_cache_tokens = trajectory.final_metrics.total_cached_tokens
|
|
233
|
+
context.cost_usd = trajectory.final_metrics.total_cost_usd
|
|
234
|
+
context.metadata = {
|
|
235
|
+
"pi_events": self._OUTPUT_FILENAME,
|
|
236
|
+
"atif": "trajectory.json",
|
|
237
|
+
"source_snapshot": (self._spec.get("source") or {}).get("snapshot_hash"),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _materialize_workspace(source: Path, destination: Path) -> None:
|
|
242
|
+
destination.mkdir()
|
|
243
|
+
for relative in sorted(_workspace_paths(source), key=lambda path: path.as_posix()):
|
|
244
|
+
if _excluded_workspace_path(relative):
|
|
245
|
+
continue
|
|
246
|
+
source_path = source / relative
|
|
247
|
+
if not source_path.exists() and not source_path.is_symlink():
|
|
248
|
+
continue
|
|
249
|
+
target_path = destination / relative
|
|
250
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
if source_path.is_symlink():
|
|
252
|
+
target_path.symlink_to(
|
|
253
|
+
os.readlink(source_path), target_is_directory=source_path.is_dir()
|
|
254
|
+
)
|
|
255
|
+
elif source_path.is_file():
|
|
256
|
+
shutil.copy2(source_path, target_path)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _workspace_paths(source: Path) -> list[Path]:
|
|
260
|
+
paths: list[Path] = []
|
|
261
|
+
for current, directories, files in os.walk(source, followlinks=False):
|
|
262
|
+
current_path = Path(current)
|
|
263
|
+
kept_directories: list[str] = []
|
|
264
|
+
for name in directories:
|
|
265
|
+
relative = (current_path / name).relative_to(source)
|
|
266
|
+
if _excluded_workspace_path(relative):
|
|
267
|
+
continue
|
|
268
|
+
if (current_path / name).is_symlink():
|
|
269
|
+
paths.append(relative)
|
|
270
|
+
else:
|
|
271
|
+
kept_directories.append(name)
|
|
272
|
+
directories[:] = kept_directories
|
|
273
|
+
paths.extend((current_path / name).relative_to(source) for name in files)
|
|
274
|
+
return paths
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _excluded_workspace_path(path: Path) -> bool:
|
|
278
|
+
if path.is_absolute() or ".." in path.parts:
|
|
279
|
+
return True
|
|
280
|
+
if any(part in _EXCLUDED_DIRECTORY_NAMES for part in path.parts):
|
|
281
|
+
return True
|
|
282
|
+
if any(part in _EXCLUDED_EVAL_DIRECTORIES for part in path.parts):
|
|
283
|
+
return True
|
|
284
|
+
name = path.name
|
|
285
|
+
if name == ".DS_Store" or name in _EXCLUDED_SECRET_FILES:
|
|
286
|
+
return True
|
|
287
|
+
if name == ".env":
|
|
288
|
+
return True
|
|
289
|
+
if name.startswith(".env.") and Path(name).suffix not in _SAFE_ENV_SUFFIXES:
|
|
290
|
+
return True
|
|
291
|
+
return path.suffix.lower() in _SECRET_SUFFIXES
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _selected_prompt(spec: dict[str, Any], instruction: str) -> str:
|
|
295
|
+
source = spec.get("source") or {}
|
|
296
|
+
selected_prompt = (spec.get("input") or {}).get("prompt")
|
|
297
|
+
prompt = selected_prompt if source.get("kind") == "production_task" else instruction
|
|
298
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
299
|
+
raise ValueError("Introspection evaluation prompt must be non-empty")
|
|
300
|
+
return prompt
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _recipe_run_command(args: list[str], output_filename: str) -> str:
|
|
304
|
+
return (
|
|
305
|
+
"set -o pipefail; . ~/.nvm/nvm.sh; "
|
|
306
|
+
f"{shlex.join(args)} 2>&1 </dev/null | "
|
|
307
|
+
f"stdbuf -oL tee /logs/agent/{shlex.quote(output_filename)}"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _model_environment(model_name: str | None) -> dict[str, str]:
|
|
312
|
+
if not model_name or "/" not in model_name:
|
|
313
|
+
return {}
|
|
314
|
+
provider = model_name.split("/", 1)[0]
|
|
315
|
+
names = {
|
|
316
|
+
"amazon-bedrock": [
|
|
317
|
+
"AWS_ACCESS_KEY_ID",
|
|
318
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
319
|
+
"AWS_SESSION_TOKEN",
|
|
320
|
+
"AWS_REGION",
|
|
321
|
+
],
|
|
322
|
+
"anthropic": ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
|
|
323
|
+
"github-copilot": ["GITHUB_TOKEN"],
|
|
324
|
+
"google": [
|
|
325
|
+
"GEMINI_API_KEY",
|
|
326
|
+
"GOOGLE_GENERATIVE_AI_API_KEY",
|
|
327
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
328
|
+
"GOOGLE_CLOUD_LOCATION",
|
|
329
|
+
"GOOGLE_GENAI_USE_VERTEXAI",
|
|
330
|
+
"GOOGLE_API_KEY",
|
|
331
|
+
],
|
|
332
|
+
"groq": ["GROQ_API_KEY"],
|
|
333
|
+
"huggingface": ["HF_TOKEN"],
|
|
334
|
+
"mistral": ["MISTRAL_API_KEY"],
|
|
335
|
+
"openai": ["OPENAI_API_KEY"],
|
|
336
|
+
"openrouter": ["OPENROUTER_API_KEY"],
|
|
337
|
+
"xai": ["XAI_API_KEY"],
|
|
338
|
+
}.get(provider, [])
|
|
339
|
+
return {name: os.environ[name] for name in names if name in os.environ}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _pi_events_to_atif(path: Path, model_name: str | None) -> Trajectory:
|
|
343
|
+
messages: list[dict[str, Any]] = []
|
|
344
|
+
for line in path.read_text().splitlines():
|
|
345
|
+
try:
|
|
346
|
+
event = json.loads(line)
|
|
347
|
+
except json.JSONDecodeError:
|
|
348
|
+
continue
|
|
349
|
+
if event.get("type") == "message_end" and isinstance(event.get("message"), dict):
|
|
350
|
+
messages.append(event["message"])
|
|
351
|
+
|
|
352
|
+
steps: list[Step] = []
|
|
353
|
+
calls: dict[str, tuple[int, ToolCall]] = {}
|
|
354
|
+
totals = {"input": 0, "output": 0, "cache": 0, "cost": 0.0}
|
|
355
|
+
for message in messages:
|
|
356
|
+
role = message.get("role")
|
|
357
|
+
if role == "user":
|
|
358
|
+
content = message.get("content")
|
|
359
|
+
if isinstance(content, list):
|
|
360
|
+
content = "".join(
|
|
361
|
+
block.get("text", "") for block in content if block.get("type") == "text"
|
|
362
|
+
)
|
|
363
|
+
steps.append(Step(step_id=len(steps) + 1, source="user", message=content or ""))
|
|
364
|
+
elif role == "assistant":
|
|
365
|
+
text: list[str] = []
|
|
366
|
+
thinking: list[str] = []
|
|
367
|
+
tool_calls: list[ToolCall] = []
|
|
368
|
+
for block in message.get("content") or []:
|
|
369
|
+
if block.get("type") == "text":
|
|
370
|
+
text.append(block.get("text", ""))
|
|
371
|
+
elif block.get("type") == "thinking":
|
|
372
|
+
thinking.append(block.get("thinking", ""))
|
|
373
|
+
elif block.get("type") == "toolCall":
|
|
374
|
+
call = ToolCall(
|
|
375
|
+
tool_call_id=block["id"],
|
|
376
|
+
function_name=block["name"],
|
|
377
|
+
arguments=block.get("arguments") or {},
|
|
378
|
+
)
|
|
379
|
+
tool_calls.append(call)
|
|
380
|
+
usage = message.get("usage") or {}
|
|
381
|
+
cost = (usage.get("cost") or {}).get("total", 0) or 0
|
|
382
|
+
totals["input"] += usage.get("input", 0) or 0
|
|
383
|
+
totals["output"] += usage.get("output", 0) or 0
|
|
384
|
+
totals["cache"] += usage.get("cacheRead", 0) or 0
|
|
385
|
+
totals["cost"] += cost
|
|
386
|
+
step = Step(
|
|
387
|
+
step_id=len(steps) + 1,
|
|
388
|
+
source="agent",
|
|
389
|
+
model_name=model_name,
|
|
390
|
+
message="".join(text),
|
|
391
|
+
reasoning_content="\n".join(thinking) or None,
|
|
392
|
+
tool_calls=tool_calls or None,
|
|
393
|
+
metrics=Metrics(
|
|
394
|
+
prompt_tokens=usage.get("input", 0) or 0,
|
|
395
|
+
completion_tokens=usage.get("output", 0) or 0,
|
|
396
|
+
cached_tokens=usage.get("cacheRead", 0) or 0,
|
|
397
|
+
cost_usd=cost,
|
|
398
|
+
),
|
|
399
|
+
llm_call_count=1,
|
|
400
|
+
)
|
|
401
|
+
steps.append(step)
|
|
402
|
+
for call in tool_calls:
|
|
403
|
+
calls[call.tool_call_id] = (len(steps) - 1, call)
|
|
404
|
+
elif role == "toolResult" and message.get("toolCallId") in calls:
|
|
405
|
+
step_index, _ = calls[message["toolCallId"]]
|
|
406
|
+
content = message.get("content") or []
|
|
407
|
+
rendered = "".join(
|
|
408
|
+
block.get("text", "") for block in content if block.get("type") == "text"
|
|
409
|
+
)
|
|
410
|
+
step = steps[step_index]
|
|
411
|
+
results = list(step.observation.results) if step.observation else []
|
|
412
|
+
results.append(
|
|
413
|
+
ObservationResult(
|
|
414
|
+
source_call_id=message["toolCallId"],
|
|
415
|
+
content=rendered,
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
step.observation = Observation(results=results)
|
|
419
|
+
|
|
420
|
+
if not steps:
|
|
421
|
+
steps.append(Step(step_id=1, source="agent", message="No Pi events were recorded", llm_call_count=0))
|
|
422
|
+
return Trajectory(
|
|
423
|
+
session_id=None,
|
|
424
|
+
agent=Agent(name="introspection-recipe", version=_ADAPTER_VERSION, model_name=model_name),
|
|
425
|
+
steps=steps,
|
|
426
|
+
final_metrics=FinalMetrics(
|
|
427
|
+
total_prompt_tokens=int(totals["input"]),
|
|
428
|
+
total_completion_tokens=int(totals["output"]),
|
|
429
|
+
total_cached_tokens=int(totals["cache"]),
|
|
430
|
+
total_cost_usd=float(totals["cost"]),
|
|
431
|
+
total_steps=len(steps),
|
|
432
|
+
),
|
|
433
|
+
)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import AsyncMock, Mock, patch
|
|
10
|
+
|
|
11
|
+
from introspection_harbor.agent import (
|
|
12
|
+
IntrospectionRecipeAgent,
|
|
13
|
+
_materialize_workspace,
|
|
14
|
+
_model_environment,
|
|
15
|
+
_node_prerequisite_command,
|
|
16
|
+
_recipe_run_command,
|
|
17
|
+
_selected_prompt,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AgentHelpersTest(unittest.TestCase):
|
|
22
|
+
def test_production_run_uses_selected_prompt(self) -> None:
|
|
23
|
+
spec = {
|
|
24
|
+
"source": {"kind": "production_task"},
|
|
25
|
+
"input": {"prompt": "counterfactual prompt"},
|
|
26
|
+
}
|
|
27
|
+
self.assertEqual(_selected_prompt(spec, "Harbor instruction"), "counterfactual prompt")
|
|
28
|
+
|
|
29
|
+
def test_authored_run_uses_harbor_instruction(self) -> None:
|
|
30
|
+
spec = {"source": {"kind": "authored"}, "input": {"prompt": None}}
|
|
31
|
+
self.assertEqual(_selected_prompt(spec, "Harbor instruction"), "Harbor instruction")
|
|
32
|
+
|
|
33
|
+
def test_recipe_pipeline_preserves_the_first_failure(self) -> None:
|
|
34
|
+
command = _recipe_run_command(["introspection", "local"], "pi.jsonl")
|
|
35
|
+
self.assertTrue(command.startswith("set -o pipefail;"))
|
|
36
|
+
|
|
37
|
+
def test_bedrock_forwards_temporary_session_credentials(self) -> None:
|
|
38
|
+
credentials = {
|
|
39
|
+
"AWS_ACCESS_KEY_ID": "key",
|
|
40
|
+
"AWS_SECRET_ACCESS_KEY": "secret",
|
|
41
|
+
"AWS_SESSION_TOKEN": "token",
|
|
42
|
+
"AWS_REGION": "us-east-1",
|
|
43
|
+
}
|
|
44
|
+
with patch.dict(os.environ, credentials, clear=True):
|
|
45
|
+
self.assertEqual(_model_environment("amazon-bedrock/model"), credentials)
|
|
46
|
+
|
|
47
|
+
def test_google_does_not_forward_host_credential_file_paths(self) -> None:
|
|
48
|
+
credentials = {
|
|
49
|
+
"GEMINI_API_KEY": "key",
|
|
50
|
+
"GOOGLE_APPLICATION_CREDENTIALS": "/host/service-account.json",
|
|
51
|
+
}
|
|
52
|
+
with patch.dict(os.environ, credentials, clear=True):
|
|
53
|
+
self.assertEqual(
|
|
54
|
+
_model_environment("google/model"), {"GEMINI_API_KEY": "key"}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def test_node_setup_supports_non_debian_task_images(self) -> None:
|
|
58
|
+
command = _node_prerequisite_command()
|
|
59
|
+
self.assertIn("command -v apk", command)
|
|
60
|
+
self.assertIn("command -v dnf", command)
|
|
61
|
+
self.assertIn("command -v yum", command)
|
|
62
|
+
self.assertIn("command -v zypper", command)
|
|
63
|
+
self.assertIn("command -v curl", command)
|
|
64
|
+
self.assertFalse(command.startswith("apt-get"))
|
|
65
|
+
|
|
66
|
+
def test_workspace_staging_preserves_monorepo_sources_and_excludes_secrets(self) -> None:
|
|
67
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
68
|
+
root = Path(temp_dir) / "source"
|
|
69
|
+
staged = Path(temp_dir) / "staged"
|
|
70
|
+
files = {
|
|
71
|
+
"package.json": "{}",
|
|
72
|
+
"pnpm-lock.yaml": "lockfileVersion: '9.0'",
|
|
73
|
+
"pnpm-workspace.yaml": "packages: ['packages/*']",
|
|
74
|
+
".introspection/runtimes/agent.yaml": "runtime: {}",
|
|
75
|
+
"packages/agent/package.json": "{}",
|
|
76
|
+
"packages/agent/.gitignore": "dist/\n",
|
|
77
|
+
"packages/agent/dist/index.js": "export const generated = true;",
|
|
78
|
+
"packages/shared/index.ts": "export const shared = true;",
|
|
79
|
+
".env.example": "TOKEN=replace-me",
|
|
80
|
+
".env": "TOKEN=secret",
|
|
81
|
+
".npmrc": "//registry.example/:_authToken=secret",
|
|
82
|
+
"certificate.pem": "secret",
|
|
83
|
+
"node_modules/pkg/index.js": "generated",
|
|
84
|
+
".introspection/eval-runs/run/result.json": "{}",
|
|
85
|
+
}
|
|
86
|
+
for relative, contents in files.items():
|
|
87
|
+
path = root / relative
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
path.write_text(contents)
|
|
90
|
+
subprocess.run(["git", "init", "--quiet", root], check=True)
|
|
91
|
+
|
|
92
|
+
_materialize_workspace(root, staged)
|
|
93
|
+
|
|
94
|
+
for relative in [
|
|
95
|
+
"package.json",
|
|
96
|
+
"pnpm-lock.yaml",
|
|
97
|
+
"pnpm-workspace.yaml",
|
|
98
|
+
".introspection/runtimes/agent.yaml",
|
|
99
|
+
"packages/agent/package.json",
|
|
100
|
+
"packages/agent/dist/index.js",
|
|
101
|
+
"packages/shared/index.ts",
|
|
102
|
+
".env.example",
|
|
103
|
+
]:
|
|
104
|
+
self.assertTrue((staged / relative).is_file(), relative)
|
|
105
|
+
for relative in [
|
|
106
|
+
".env",
|
|
107
|
+
".npmrc",
|
|
108
|
+
"certificate.pem",
|
|
109
|
+
".git/config",
|
|
110
|
+
"node_modules/pkg/index.js",
|
|
111
|
+
".introspection/eval-runs/run/result.json",
|
|
112
|
+
]:
|
|
113
|
+
self.assertFalse((staged / relative).exists(), relative)
|
|
114
|
+
|
|
115
|
+
def test_workspace_staging_preserves_directory_symlink_type(self) -> None:
|
|
116
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
117
|
+
root = Path(temp_dir) / "source"
|
|
118
|
+
staged = Path(temp_dir) / "staged"
|
|
119
|
+
target = root / "packages/shared"
|
|
120
|
+
target.mkdir(parents=True)
|
|
121
|
+
link = root / "packages/linked"
|
|
122
|
+
try:
|
|
123
|
+
link.symlink_to("shared", target_is_directory=True)
|
|
124
|
+
except OSError as error:
|
|
125
|
+
self.skipTest(f"directory symlinks are unavailable: {error}")
|
|
126
|
+
|
|
127
|
+
with patch.object(Path, "symlink_to") as symlink_to:
|
|
128
|
+
_materialize_workspace(root, staged)
|
|
129
|
+
|
|
130
|
+
symlink_to.assert_called_once()
|
|
131
|
+
self.assertTrue(symlink_to.call_args.kwargs["target_is_directory"])
|
|
132
|
+
|
|
133
|
+
def test_harbor_run_omits_agent_when_recipe_default_is_selected(self) -> None:
|
|
134
|
+
instance = object.__new__(IntrospectionRecipeAgent)
|
|
135
|
+
instance._spec = {
|
|
136
|
+
"candidate": {"runtime": "refunds", "agent": None, "model": None},
|
|
137
|
+
"source": {"kind": "authored"},
|
|
138
|
+
"input": {},
|
|
139
|
+
}
|
|
140
|
+
instance.model_name = None
|
|
141
|
+
instance._OUTPUT_FILENAME = "pi.jsonl"
|
|
142
|
+
environment = Mock()
|
|
143
|
+
|
|
144
|
+
async def capture_exec(*args, **kwargs):
|
|
145
|
+
environment.command = kwargs["command"]
|
|
146
|
+
environment.cwd = kwargs["cwd"]
|
|
147
|
+
|
|
148
|
+
instance.exec_as_agent = capture_exec
|
|
149
|
+
asyncio.run(instance.run.__wrapped__(instance, "prompt", environment, Mock()))
|
|
150
|
+
self.assertIn("--work-dir /tmp/introspection-recipe-source", environment.command)
|
|
151
|
+
self.assertNotIn("--agent", environment.command)
|
|
152
|
+
self.assertEqual(environment.cwd, "/app")
|
|
153
|
+
|
|
154
|
+
def test_harbor_run_selects_inherited_agent_by_declared_name(self) -> None:
|
|
155
|
+
instance = object.__new__(IntrospectionRecipeAgent)
|
|
156
|
+
instance._spec = {
|
|
157
|
+
"candidate": {"runtime": "refunds", "agent": "agent2", "model": None},
|
|
158
|
+
"source": {"kind": "authored"},
|
|
159
|
+
"input": {},
|
|
160
|
+
}
|
|
161
|
+
instance.model_name = None
|
|
162
|
+
instance._OUTPUT_FILENAME = "pi.jsonl"
|
|
163
|
+
environment = Mock()
|
|
164
|
+
|
|
165
|
+
async def capture_exec(*args, **kwargs):
|
|
166
|
+
environment.command = kwargs["command"]
|
|
167
|
+
|
|
168
|
+
instance.exec_as_agent = capture_exec
|
|
169
|
+
asyncio.run(instance.run.__wrapped__(instance, "prompt", environment, Mock()))
|
|
170
|
+
self.assertIn("--agent agent2", environment.command)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class AgentStagingTest(unittest.IsolatedAsyncioTestCase):
|
|
174
|
+
async def test_install_uploads_filtered_workspace_without_tar_or_opt_layout(self) -> None:
|
|
175
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
176
|
+
workspace = Path(temp_dir) / "workspace"
|
|
177
|
+
workspace.mkdir()
|
|
178
|
+
(workspace / "package.json").write_text("{}")
|
|
179
|
+
|
|
180
|
+
instance = object.__new__(IntrospectionRecipeAgent)
|
|
181
|
+
instance._spec = {
|
|
182
|
+
"candidate": {"cli_version": "0.23.0"},
|
|
183
|
+
"input": {},
|
|
184
|
+
}
|
|
185
|
+
instance._workspace_path = workspace
|
|
186
|
+
instance.exec_as_root = AsyncMock()
|
|
187
|
+
instance.exec_as_agent = AsyncMock()
|
|
188
|
+
environment = Mock()
|
|
189
|
+
environment.upload_dir = AsyncMock()
|
|
190
|
+
environment.upload_file = AsyncMock()
|
|
191
|
+
|
|
192
|
+
await instance.install(environment)
|
|
193
|
+
|
|
194
|
+
upload = environment.upload_dir.await_args
|
|
195
|
+
self.assertEqual(upload.args[1], "/tmp/introspection-recipe-source")
|
|
196
|
+
self.assertEqual(environment.upload_file.await_count, 0)
|
|
197
|
+
commands = [
|
|
198
|
+
call.kwargs["command"] for call in instance.exec_as_root.await_args_list
|
|
199
|
+
]
|
|
200
|
+
commands.append(instance.exec_as_agent.await_args.kwargs["command"])
|
|
201
|
+
combined = "\n".join(commands)
|
|
202
|
+
self.assertIn("nvm install 24", combined)
|
|
203
|
+
self.assertNotIn("nvm install 22", combined)
|
|
204
|
+
self.assertNotIn("/opt/introspection-workspace", combined)
|
|
205
|
+
self.assertNotIn("recipe-source.tar", combined)
|
|
206
|
+
self.assertNotIn("tar -xf", combined)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
unittest.main()
|