lots of change - all to start my 3rd redo
This commit is contained in:
@@ -1,286 +0,0 @@
|
||||
version = 1
|
||||
|
||||
[[harness]]
|
||||
id = "claude-code"
|
||||
label = "Claude Code"
|
||||
agent_import_path = "snapshot_agent:SnapshotClaudeCode"
|
||||
# `[metadata] browser = true` swaps in these: same reduced toolset plus `Read`, so an agent
|
||||
# given a browser can look at the screenshot it just took. Distinct classes with distinct
|
||||
# names, because a different toolset is a different agent.
|
||||
agent_import_path_browser = "snapshot_agent:BrowserSnapshotClaudeCode"
|
||||
agent_import_path_single_turn_browser = "snapshot_agent:BrowserPreinstalledClaudeCode"
|
||||
agent_import_path_single_turn = "snapshot_agent:PreinstalledClaudeCode"
|
||||
import_path_aliases = [
|
||||
"snapshot_agent:FullToolsetSnapshotClaudeCode",
|
||||
"snapshot_agent:FullToolsetPreinstalledClaudeCode",
|
||||
"harbor.agents.installed.claude_code:ClaudeCode",
|
||||
]
|
||||
legacy_bare_model_rows = true
|
||||
default_model = "claude-opus-5[1m]"
|
||||
model_id_shape = "bare"
|
||||
effort_kwarg = "reasoning_effort"
|
||||
effort_default = "max"
|
||||
fast_kwarg = "fast_mode"
|
||||
key_env = "ANTHROPIC_API_KEY"
|
||||
base_url_env = "ANTHROPIC_BASE_URL"
|
||||
proxy_path = "anthropic"
|
||||
writes_atif = true
|
||||
capture = true
|
||||
seed_native = true
|
||||
seed_atif = true
|
||||
authoring = true
|
||||
cli = "claude"
|
||||
install = "for i in 1 2 3; do curl -fsSL https://claude.ai/install.sh | bash && break; echo \"claude install attempt $i failed; retrying in 10s\" >&2; sleep 10; done"
|
||||
# No agent_config: claude reduces its toolset with `--tools`, not `-c key=value`, so the
|
||||
# reduction is a launch flag here and `--tools Bash` in snapshot_agent.py for the trial.
|
||||
# Two expressions of one intent, which the $RACCOON_AGENT_FLAGS guard cannot police —
|
||||
# unlike model and effort, which are interpolated from this row.
|
||||
explore_launch = """exec claude --model '$RACCOON_MODEL' --effort $RACCOON_EFFORT --tools "$RACCOON_TOOLS" --append-system-prompt "$RACCOON_TOOLSET_NOTE" --plugin-dir /workspace/plugins/create-snapshot --dangerously-skip-permissions "$@""""
|
||||
|
||||
[[harness]]
|
||||
id = "codex"
|
||||
label = "OpenAI Codex CLI"
|
||||
agent_import_path = "codex_agent:NativeSnapshotCodex"
|
||||
agent_import_path_single_turn = "codex_agent:SystemNodeCodex"
|
||||
import_path_aliases = [
|
||||
"codex_agent:InlineSnapshotCodex",
|
||||
"harbor.agents.installed.codex:Codex",
|
||||
]
|
||||
legacy_bare_model_rows = true
|
||||
default_model = "gpt-5.6-sol"
|
||||
model_id_shape = "bare"
|
||||
effort_kwarg = "reasoning_effort"
|
||||
effort_default = "max"
|
||||
key_env = "OPENAI_API_KEY"
|
||||
base_url_env = "OPENAI_BASE_URL"
|
||||
proxy_path = "openai/v1"
|
||||
writes_atif = true
|
||||
capture = true
|
||||
seed_native = true
|
||||
seed_atif = true
|
||||
authoring = true
|
||||
cli = "codex"
|
||||
install = "for i in 1 2 3; do curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh && break; echo \"codex install attempt $i failed; retrying in 10s\" >&2; sleep 10; done"
|
||||
skills_dir = "$HOME/.agents/skills"
|
||||
config_path = "${CODEX_HOME:-$HOME/.codex}/config.toml"
|
||||
auth_path = "${CODEX_HOME:-$HOME/.codex}/auth.json"
|
||||
auth_key_env = "OPENAI_API_KEY"
|
||||
agent_config = """
|
||||
web_search = "disabled"
|
||||
|
||||
[agents]
|
||||
enabled = false
|
||||
|
||||
[tools]
|
||||
update_plan = { enabled = false }
|
||||
experimental_request_user_input = { enabled = false }
|
||||
|
||||
[features]
|
||||
goals = false
|
||||
multi_agent = false
|
||||
multi_agent_v2 = false
|
||||
memories = false
|
||||
external_agent_memory_import = false
|
||||
"""
|
||||
# A provider of our own, not the built-in `openai`: codex reserves built-in provider ids,
|
||||
# and env_http_headers — the only place codex can be told to send the call-origin header —
|
||||
# is a per-provider setting. base_url has to live in the table with it (a provider without
|
||||
# one silently falls back to api.openai.com), so harness_refresh_config_keys refreshes
|
||||
# [model_providers.*] keys as well as root ones.
|
||||
container_config = """
|
||||
model_provider = "llm-proxy"
|
||||
|
||||
[model_providers.llm-proxy]
|
||||
name = "LLM proxy"
|
||||
base_url = "${OPENAI_BASE_URL}"
|
||||
# A custom provider reads its key from this env var and never from auth.json, so it
|
||||
# names the one key .env actually carries. That makes the key live per launch rather
|
||||
# than baked at container create — better than the auth-file path it replaces.
|
||||
env_key = "ANTHROPIC_API_KEY"
|
||||
wire_api = "responses"
|
||||
env_http_headers = { "X-Surge-Client-Metadata" = "LLM_CALL_METADATA" }
|
||||
"""
|
||||
explore_config = """
|
||||
[hooks]
|
||||
SessionStart = [ { hooks = [ { type = "command", command = "/workspace/plugins/create-snapshot/bin/save-session-info.mjs" } ] } ]
|
||||
UserPromptSubmit = [ { hooks = [ { type = "command", command = "/workspace/plugins/create-snapshot/bin/checkpoint-workspace.mjs" } ] } ]
|
||||
"""
|
||||
explore_launch = """exec codex $RACCOON_AGENT_FLAGS --model $RACCOON_MODEL -c model_reasoning_effort=$RACCOON_EFFORT ${RACCOON_BROWSER_FLAGS[@]+"${RACCOON_BROWSER_FLAGS[@]}"} --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust "$@""""
|
||||
|
||||
[[harness]]
|
||||
id = "gemini-cli"
|
||||
label = "Gemini CLI"
|
||||
agent_import_path = "gemini_agent:NativeSnapshotGeminiCli"
|
||||
agent_import_path_single_turn = "gemini_agent:SystemNodeGeminiCli"
|
||||
import_path_aliases = ["harbor.agents.installed.gemini_cli:GeminiCli"]
|
||||
legacy_bare_model_rows = true
|
||||
default_model = "gemini-3.5-flash"
|
||||
model_id_shape = "provider/model"
|
||||
effort_kwarg = "reasoning_effort"
|
||||
effort_default = "high"
|
||||
key_env = "GEMINI_API_KEY"
|
||||
base_url_env = "GEMINI_API_BASE"
|
||||
proxy_path = "gemini"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
seed_native = true
|
||||
seed_atif = false
|
||||
|
||||
[[harness]]
|
||||
id = "antigravity-cli"
|
||||
label = "Antigravity CLI"
|
||||
agent_import_path = "harness_agents:BenchAntigravity"
|
||||
import_path_aliases = ["harbor.agents.installed.antigravity_cli:AntigravityCli"]
|
||||
legacy_bare_model_rows = false
|
||||
# The prefix is load-bearing: harbor's adapter raises without a "/" in the id.
|
||||
# agy carries its own model catalogue and DROPS entries between point releases
|
||||
# (1.1.25 removed gemini-3.5-flash, breaking every run). If trials start failing
|
||||
# with "not recognized as a known model", run `agy --model bogus --prompt=x` to
|
||||
# print the current catalogue and update this.
|
||||
default_model = "google/gemini-3.8-flash"
|
||||
model_id_shape = "provider/model"
|
||||
# Not optional: agy refuses a Gemini 3 model with no --effort ("requires --effort
|
||||
# (available: low, medium, high)"). low/high are safe on pro and flash alike.
|
||||
effort_kwarg = "reasoning_effort"
|
||||
effort_default = "high"
|
||||
key_env = "GEMINI_API_KEY"
|
||||
base_url_env = "GOOGLE_GEMINI_BASE_URL"
|
||||
proxy_path = "gemini"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
# agy cannot be handed externally-produced history, so multi-turn tasks must
|
||||
# hard-fail rather than silently run cold. See work-logs/antigravity-harness.md.
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
|
||||
[[harness]]
|
||||
id = "opencode"
|
||||
label = "OpenCode"
|
||||
agent_import_path = "harness_agents:BenchOpenCode"
|
||||
legacy_bare_model_rows = false
|
||||
model_id_shape = "provider/model"
|
||||
effort_kwarg = ""
|
||||
key_env = "ANTHROPIC_API_KEY"
|
||||
base_url_env = "ANTHROPIC_BASE_URL"
|
||||
proxy_path = "anthropic"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
flaky_hangs = true
|
||||
|
||||
[[harness]]
|
||||
id = "goose"
|
||||
label = "Goose"
|
||||
agent_import_path = "harness_agents:BenchGoose"
|
||||
legacy_bare_model_rows = false
|
||||
model_id_shape = "provider/model"
|
||||
effort_kwarg = ""
|
||||
key_env = "ANTHROPIC_API_KEY"
|
||||
base_url_env = "ANTHROPIC_BASE_URL"
|
||||
proxy_path = "anthropic"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
|
||||
[[harness]]
|
||||
id = "mini-swe-agent"
|
||||
label = "mini-swe-agent"
|
||||
agent_import_path = "harness_agents:BenchMiniSweAgent"
|
||||
legacy_bare_model_rows = false
|
||||
model_id_shape = "provider/model"
|
||||
effort_kwarg = ""
|
||||
key_env = "ANTHROPIC_API_KEY"
|
||||
base_url_env = "ANTHROPIC_BASE_URL"
|
||||
proxy_path = "anthropic"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
|
||||
[[harness]]
|
||||
id = "cline-cli"
|
||||
label = "Cline CLI"
|
||||
agent_import_path = "harness_agents:BenchCline"
|
||||
legacy_bare_model_rows = false
|
||||
model_id_shape = "provider:model"
|
||||
effort_kwarg = ""
|
||||
key_env = "ANTHROPIC_API_KEY"
|
||||
base_url_env = "ANTHROPIC_BASE_URL"
|
||||
proxy_path = "anthropic"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
|
||||
[[harness]]
|
||||
id = "crush"
|
||||
label = "Crush"
|
||||
agent_import_path = "harness_agents:Crush"
|
||||
legacy_bare_model_rows = false
|
||||
model_id_shape = "provider/model"
|
||||
effort_kwarg = ""
|
||||
key_env = "ANTHROPIC_API_KEY"
|
||||
base_url_env = "ANTHROPIC_BASE_URL"
|
||||
proxy_path = "anthropic"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
flaky_hangs = true
|
||||
|
||||
[[harness]]
|
||||
id = "amp"
|
||||
label = "Amp"
|
||||
agent_import_path = "harness_agents:Amp"
|
||||
legacy_bare_model_rows = false
|
||||
model_id_shape = "bare"
|
||||
effort_kwarg = ""
|
||||
key_env = "AMP_API_KEY"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
enabled = false
|
||||
|
||||
[[harness]]
|
||||
id = "cursor-cli"
|
||||
label = "Cursor CLI"
|
||||
agent_import_path = "harness_agents:BenchCursorCli"
|
||||
legacy_bare_model_rows = false
|
||||
model_id_shape = "bare"
|
||||
effort_kwarg = ""
|
||||
key_env = "CURSOR_API_KEY"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
enabled = false
|
||||
|
||||
[[harness]]
|
||||
id = "copilot-cli"
|
||||
label = "GitHub Copilot CLI"
|
||||
agent_import_path = "harness_agents:BenchCopilotCli"
|
||||
legacy_bare_model_rows = false
|
||||
model_id_shape = "bare"
|
||||
effort_kwarg = ""
|
||||
key_env = "GITHUB_TOKEN"
|
||||
writes_atif = true
|
||||
capture = false
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
enabled = false
|
||||
|
||||
[[harness]]
|
||||
id = "aider"
|
||||
label = "Aider"
|
||||
agent_import_path = "harness_agents:BenchAider"
|
||||
legacy_bare_model_rows = false
|
||||
model_id_shape = "provider/model"
|
||||
effort_kwarg = ""
|
||||
writes_atif = false
|
||||
capture = false
|
||||
seed_native = false
|
||||
seed_atif = false
|
||||
enabled = false
|
||||
@@ -1,328 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Read the harness registry and derive per-harness credentials from it.
|
||||
#
|
||||
# Source it — the whole point is exporting into the caller's environment, which a subshell
|
||||
# would lose:
|
||||
#
|
||||
# HARNESS_SCRIPTS_DIR=/workspace/scripts . /workspace/scripts/lib/harness-credentials.sh
|
||||
# harness_setup_credentials
|
||||
#
|
||||
# Three callers: `harbor-run`, which needs only this; `refresh-harness-auth`, which
|
||||
# re-derives and rewrites the auth files before an interactive launch; and
|
||||
# `setup-harnesses.sh`, which sources it and adds installs, config writing and launchers
|
||||
# on top.
|
||||
#
|
||||
# No -e here — this file is SOURCED, and shell options belong to the caller's shell (both
|
||||
# post-creates run with -e). An unguarded failure below therefore aborts container
|
||||
# creation, which is why every failure site is individually guarded rather than relying on
|
||||
# this line.
|
||||
set -uo pipefail
|
||||
|
||||
_HARNESS_REGISTRY_DIR="${HARNESS_SCRIPTS_DIR:-/workspace/scripts}"
|
||||
|
||||
# The registry is read with tomllib (stdlib from 3.11), and `python3` is not always new
|
||||
# enough — macOS ships 3.9, and a container may symlink an older managed interpreter. Pick
|
||||
# the first one that can actually import it rather than assuming.
|
||||
_raccoon_python() {
|
||||
local p
|
||||
for p in "${RACCOON_PYTHON:-}" python3 python3.13 python3.12 python3.11; do
|
||||
[ -n "$p" ] || continue
|
||||
command -v "$p" >/dev/null 2>&1 || continue
|
||||
if "$p" -c "import tomllib" >/dev/null 2>&1; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_harness_query() {
|
||||
local py
|
||||
py=$(_raccoon_python) || return 1
|
||||
"$py" "$_HARNESS_REGISTRY_DIR/resolve_harness.py" "$@"
|
||||
}
|
||||
|
||||
# Drop every whitespace character from a value read out of .env. A Windows-saved .env leaves a
|
||||
# \r on each value, which reaches the proxy as a 401; no key or base URL legitimately contains
|
||||
# whitespace anywhere, so deleting rather than trimming needs no cases.
|
||||
_harness_trim() {
|
||||
local out
|
||||
# Fall back to the raw value: a trim that cannot run must never turn a working key into an
|
||||
# empty one, which is what an unavailable `tr` would otherwise do to every caller.
|
||||
out="$(printf '%s' "$1" | tr -d '[:space:]' 2>/dev/null)" || out="$1"
|
||||
printf '%s' "${out:-$1}"
|
||||
}
|
||||
|
||||
# The proxy root: the worker's ANTHROPIC_BASE_URL minus its provider path.
|
||||
_harness_proxy_root() {
|
||||
local base_url
|
||||
base_url="$(_harness_trim "${ANTHROPIC_BASE_URL:-}")"
|
||||
[ -n "$base_url" ] || return 1
|
||||
base_url="${base_url%"${base_url##*[!/]}"}"
|
||||
# ".../llm_proxy/projects/<id>/anthropic" -> ".../llm_proxy/projects/<id>", so each
|
||||
# harness's proxy_path composes onto the project route. Requires a path to strip: a base
|
||||
# URL that is a bare host with no path — a provider's own API root rather than the
|
||||
# proxy — would yield "https:/", handed to codex as a base URL and failing obscurely.
|
||||
case "${base_url#*://}" in
|
||||
*/*) printf '%s' "${base_url%/*}" ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
harness_setup_credentials() {
|
||||
# `|| rc=$?` and not a bare assignment: this is sourced into a `set -e` shell (see the
|
||||
# note at the top), and a bare failing assignment would exit the caller's post-create
|
||||
# outright — silently, since the failure paths below are what do the explaining.
|
||||
local root rc=0
|
||||
root="$(_harness_proxy_root)" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
echo "harness-setup: ANTHROPIC_BASE_URL (${ANTHROPIC_BASE_URL:-}) has no provider" >&2
|
||||
echo "harness-setup: path, so it is not the proxy URL other harnesses derive their" >&2
|
||||
echo "harness-setup: credentials from. claude will work; codex will not be" >&2
|
||||
echo "harness-setup: authenticated. Use the base URL you were given." >&2
|
||||
else
|
||||
echo "harness-setup: ANTHROPIC_BASE_URL unset — skipping credential derivation" >&2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
ANTHROPIC_BASE_URL="$(_harness_trim "${ANTHROPIC_BASE_URL:-}")"
|
||||
export ANTHROPIC_BASE_URL
|
||||
local key
|
||||
key="$(_harness_trim "${ANTHROPIC_API_KEY:-}")"
|
||||
if [ -z "$key" ]; then
|
||||
echo "harness-setup: ANTHROPIC_API_KEY unset — skipping credential derivation" >&2
|
||||
return 0
|
||||
fi
|
||||
# harbor-run sources .env itself and passes ANTHROPIC_* through to the trial sandbox, so
|
||||
# cleaning only the derived per-harness copies would leave a claude trial carrying the CR.
|
||||
export ANTHROPIC_API_KEY="$key"
|
||||
|
||||
local id key_env base_url_env proxy_path
|
||||
while IFS=$'\t' read -r id key_env base_url_env proxy_path; do
|
||||
[ -n "$key_env" ] || continue
|
||||
# ${!name} is an indirect expansion. Only set when empty: an explicit key wins.
|
||||
if [ -z "${!key_env:-}" ]; then
|
||||
export "$key_env=$key"
|
||||
fi
|
||||
if [ -n "$base_url_env" ] && [ -n "$proxy_path" ] && [ -z "${!base_url_env:-}" ]; then
|
||||
export "$base_url_env=$root/$proxy_path"
|
||||
fi
|
||||
echo "harness-setup: $id credentials ready ($key_env, ${base_url_env:-no base url})" >&2
|
||||
done < <(_harness_query --authoring-credentials 2>/dev/null || true)
|
||||
}
|
||||
|
||||
# Write the auth file for harnesses that read credentials from disk rather than $ENV.
|
||||
harness_write_auth() {
|
||||
local id auth_path key_env target key py
|
||||
py=$(_raccoon_python) || {
|
||||
echo "harness-setup: no python3.11+ with tomllib — skipping auth files" >&2
|
||||
return 0
|
||||
}
|
||||
while IFS=$'\t' read -r id auth_path key_env; do
|
||||
[ -n "$auth_path" ] && [ -n "$key_env" ] || continue
|
||||
# Last mile: an explicit OPENAI_API_KEY bypasses the derivation above, so trim here
|
||||
# too — this is the value that reaches the file the harness authenticates with.
|
||||
key="$(_harness_trim "${!key_env:-}")"
|
||||
if [ -z "$key" ]; then
|
||||
echo "harness-setup: $key_env unset — skipping $id auth file" >&2
|
||||
continue
|
||||
fi
|
||||
target=$(eval "printf '%s' \"$auth_path\"") || {
|
||||
echo "harness-setup: WARNING $id auth_path could not be expanded — skipping" >&2
|
||||
continue
|
||||
}
|
||||
mkdir -p "$(dirname "$target")" || {
|
||||
echo "harness-setup: WARNING $id auth dir not creatable — skipping $target" >&2
|
||||
continue
|
||||
}
|
||||
# json.dumps, not printf: a key containing a quote or backslash would otherwise
|
||||
# produce a file the CLI cannot parse, and the failure would surface as an auth
|
||||
# error rather than a malformed file.
|
||||
# 0600 tmp + rename, never a redirect onto the target: a redirect truncates the live
|
||||
# file first, so a write dying mid-flight leaves codex an EMPTY auth.json.
|
||||
if ! RACCOON_AUTH_K="$key_env" RACCOON_AUTH_V="$key" RACCOON_AUTH_TARGET="$target" \
|
||||
"$py" -c 'import json, os
|
||||
target = os.environ["RACCOON_AUTH_TARGET"]
|
||||
tmp = target + ".raccoon-tmp." + str(os.getpid())
|
||||
try:
|
||||
with os.fdopen(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as fh:
|
||||
json.dump({os.environ["RACCOON_AUTH_K"]: os.environ["RACCOON_AUTH_V"]}, fh)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, target)
|
||||
except OSError:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit(1)
|
||||
'; then
|
||||
echo "harness-setup: WARNING $id auth file NOT written — $target unwritable." >&2
|
||||
echo "harness-setup: the key already on disk (if any) is left untouched." >&2
|
||||
continue
|
||||
fi
|
||||
echo "harness-setup: $id auth -> $target" >&2
|
||||
done < <(_harness_query --auth-files 2>/dev/null || true)
|
||||
}
|
||||
|
||||
# Re-set just the root keys of a harness's config file (codex's `openai_base_url`),
|
||||
# leaving every other line — the explore surface's [hooks] table included — untouched.
|
||||
harness_refresh_config_keys() {
|
||||
local id config_path blob target py
|
||||
py=$(_raccoon_python) || return 0
|
||||
# The surface only decides what a CREATE writes. An update takes the root keys off the
|
||||
# front of the same blob, so a surface's tables survive byte-for-byte either way.
|
||||
while IFS=$'\t' read -r id config_path blob; do
|
||||
[ -n "$config_path" ] && [ -n "$blob" ] || continue
|
||||
target=$(eval "printf '%s' \"$config_path\"") || continue
|
||||
mkdir -p "$(dirname "$target")" || continue
|
||||
if printf '%s' "$blob" | base64 -d |
|
||||
RACCOON_CONFIG_TARGET="$target" "$py" -c '
|
||||
import os, re, sys, tomllib
|
||||
|
||||
HEADER = "# Generated from harness-registry.toml — edits here are overwritten."
|
||||
|
||||
target = os.environ["RACCOON_CONFIG_TARGET"]
|
||||
text = sys.stdin.read()
|
||||
# Empty counts as unresolved: writing an empty base URL would break a container whose
|
||||
# config is currently right, which is the one thing this must never do.
|
||||
if [m for m in re.finditer(r"\$\{(\w+)\}", text) if not os.environ.get(m.group(1))]:
|
||||
raise SystemExit(1)
|
||||
text = os.path.expandvars(text)
|
||||
|
||||
# Root keys, plus keys inside a [model_providers.*] table: codex reserves its built-in
|
||||
# provider ids, so the proxy URL it must follow lives in a provider table, not at the
|
||||
# root. Every other table, [hooks] on the explore surface included, is left alone.
|
||||
REFRESHABLE_TABLE = re.compile(r"\[model_providers\.[^]]+\]$")
|
||||
wanted = []
|
||||
section = None
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("["):
|
||||
section = stripped if REFRESHABLE_TABLE.match(stripped) else False
|
||||
continue
|
||||
if section is False:
|
||||
continue
|
||||
m = re.match(r"\s*\"?([A-Za-z0-9_.-]+)\"?\s*=", line)
|
||||
if m:
|
||||
wanted.append((section, m.group(1), line.rstrip()))
|
||||
if not wanted:
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def section_path(header):
|
||||
"""[model_providers.llm-proxy] -> ("model_providers", "llm-proxy")."""
|
||||
return tuple(header.strip("[]").split("."))
|
||||
|
||||
|
||||
def lookup(doc, header, key):
|
||||
"""The value a parsed config holds for a wanted key, or KeyError."""
|
||||
node = doc
|
||||
if header:
|
||||
for part in section_path(header):
|
||||
node = node[part]
|
||||
return node[key]
|
||||
|
||||
mode = None
|
||||
if os.path.exists(target):
|
||||
try:
|
||||
with open(target, encoding="utf-8") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
mode = os.stat(target).st_mode & 0o777
|
||||
except OSError:
|
||||
raise SystemExit(1)
|
||||
def span(header):
|
||||
"""The line range a section owns, or None when the file has no such section.
|
||||
|
||||
Root is everything above the first table header: a key appended below one
|
||||
would be reparented into it, so searches and inserts stay inside the span.
|
||||
"""
|
||||
heads = [i for i, l in enumerate(lines) if l.lstrip().startswith("[")]
|
||||
if header is None:
|
||||
return 0, (heads[0] if heads else len(lines))
|
||||
at = next((i for i in heads if lines[i].strip() == header), None)
|
||||
if at is None:
|
||||
return None
|
||||
after = next((i for i in heads if i > at), len(lines))
|
||||
return at + 1, after
|
||||
|
||||
# Grouped, root first, so a section this file lacks can be written whole.
|
||||
grouped = {}
|
||||
for header, key, line in wanted:
|
||||
grouped.setdefault(header, []).append((key, line))
|
||||
ordered = sorted(grouped, key=lambda h: (h is not None, h or ""))
|
||||
|
||||
changed = False
|
||||
for header in ordered:
|
||||
if span(header) is None:
|
||||
# A config written before this section existed. Write the whole table
|
||||
# rather than leave a root key naming a provider that is not there.
|
||||
if lines and lines[-1].strip():
|
||||
lines.append("")
|
||||
lines.append(header)
|
||||
lines.extend(line for _, line in grouped[header])
|
||||
changed = True
|
||||
continue
|
||||
for key, line in grouped[header]:
|
||||
# Re-read the span: an insert for an earlier key moved it.
|
||||
start, end = span(header)
|
||||
# The quoted spelling is the same key: replace rather than duplicate.
|
||||
pat = re.compile(r"\s*\"?" + re.escape(key) + r"\"?\s*=")
|
||||
at = next((i for i in range(start, end) if pat.match(lines[i])), None)
|
||||
if at is None:
|
||||
if end < len(lines) and lines[end].strip():
|
||||
lines.insert(end, "")
|
||||
lines.insert(end, line)
|
||||
changed = True
|
||||
elif lines[at] != line:
|
||||
lines[at] = line
|
||||
changed = True
|
||||
if not changed:
|
||||
raise SystemExit(0)
|
||||
out = "\n".join(lines).rstrip("\n") + "\n"
|
||||
else:
|
||||
# No file means container-create could not write one, so write what it would have:
|
||||
# on the explore surface that is the capture hooks too, not just the root keys.
|
||||
out = HEADER + "\n" + text
|
||||
|
||||
try:
|
||||
doc = tomllib.loads(out)
|
||||
except tomllib.TOMLDecodeError:
|
||||
raise SystemExit(1)
|
||||
# Parsing is not enough: a line edit can land inside a multi-line value, which still
|
||||
# parses while leaving the key unset. Require every key to have landed on the value the
|
||||
# blob asks for, in its own section — skipping sections this file does not carry.
|
||||
blob_doc = tomllib.loads(text)
|
||||
for header, key, _ in wanted:
|
||||
try:
|
||||
expected = lookup(blob_doc, header, key)
|
||||
except (KeyError, TypeError):
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
got = lookup(doc, header, key)
|
||||
except (KeyError, TypeError):
|
||||
if header is None:
|
||||
raise SystemExit(1)
|
||||
continue
|
||||
if got != expected:
|
||||
raise SystemExit(1)
|
||||
|
||||
# Pid-suffixed: two launches at once must not write the same scratch path.
|
||||
tmp = target + ".raccoon-tmp." + str(os.getpid())
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
fh.write(out)
|
||||
if mode is not None:
|
||||
os.chmod(tmp, mode)
|
||||
os.replace(tmp, target)
|
||||
except OSError:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit(1)
|
||||
'; then
|
||||
echo "harness-setup: $id config keys refreshed -> $target" >&2
|
||||
fi
|
||||
done < <(_harness_query --container-configs --surface "${RACCOON_SURFACE:-authoring}" 2>/dev/null || true)
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
"""harness_registry.py — Python loader for ``scripts/harness-registry.toml``.
|
||||
|
||||
The ONE loader for the registry: TS callers shell into ``resolve_harness.py`` rather than
|
||||
parse the TOML themselves, which is why the toolkit ships no TOML parser for TS (its
|
||||
package.json has no zod/smol-toml).
|
||||
|
||||
This module supersedes ``benchmark_models_lib``'s ``HARNESS_BY_IMPORT_PATH`` and
|
||||
``LEGACY_BARE_MODEL_AGENTS``; those should read from here rather than keep private
|
||||
copies.
|
||||
|
||||
Harbor-free and dependency-free (stdlib ``tomllib``) so it can be imported from a
|
||||
sandbox agent, a plain unit test, or the devcontainer python alike.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REGISTRY_PATH = Path(__file__).resolve().parent.parent / "harness-registry.toml"
|
||||
|
||||
MODEL_ID_SHAPES = frozenset({"bare", "provider/model", "provider:model"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Harness:
|
||||
"""One harness, as declared in harness-registry.toml."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
agent_import_path: str
|
||||
model_id_shape: str
|
||||
writes_atif: bool
|
||||
capture: bool
|
||||
seed_native: bool
|
||||
seed_atif: bool
|
||||
agent_import_path_single_turn: str | None = None
|
||||
# Browser-opt-in variants (`[metadata] browser = true`). A harness that has no variant
|
||||
# keeps its normal class: codex, for instance, gains the browser and its disclosure but
|
||||
# has no `Read` equivalent to switch toolsets for.
|
||||
agent_import_path_browser: str | None = None
|
||||
agent_import_path_single_turn_browser: str | None = None
|
||||
import_path_aliases: tuple[str, ...] = ()
|
||||
legacy_bare_model_rows: bool = False
|
||||
default_model: str | None = None
|
||||
effort_kwarg: str = ""
|
||||
effort_default: str | None = None
|
||||
# Agent kwarg that opts a trial into the harness's fast/priority serving mode
|
||||
# (claude-code: fast mode). Empty means the harness has none and --fast refuses.
|
||||
fast_kwarg: str = ""
|
||||
key_env: str | None = None
|
||||
base_url_env: str | None = None
|
||||
proxy_path: str | None = None
|
||||
flaky_hangs: bool = False
|
||||
enabled: bool = True
|
||||
# Worker-container fields; see the registry header.
|
||||
authoring: bool = False
|
||||
cli: str | None = None
|
||||
install: str | None = None
|
||||
skills_dir: str | None = None
|
||||
auth_path: str | None = None
|
||||
auth_key_env: str | None = None
|
||||
explore_launch: str | None = None
|
||||
config_path: str | None = None
|
||||
# Config the harness needs wherever it runs, trial sandbox included.
|
||||
agent_config: str | None = None
|
||||
# Config for both worker containers (explore and authoring).
|
||||
container_config: str | None = None
|
||||
# Config for the EXPLORE container only — the capture hooks, whose commands ship in
|
||||
# explore/plugins/. Writing them in authoring would register hooks against files that
|
||||
# are not there, firing on every prompt.
|
||||
explore_config: str | None = None
|
||||
# Fields added for a later phase, kept verbatim so this loader doesn't have to
|
||||
# be edited in lockstep with the schema.
|
||||
extra: dict = field(default_factory=dict, compare=False)
|
||||
|
||||
def agent_import_path_for(self, *, multi_turn: bool, browser: bool = False) -> str:
|
||||
"""Agent class to launch. Multi-turn tasks need the resuming class; a
|
||||
single-turn task given it would try to resume a session that isn't there.
|
||||
|
||||
``browser`` selects the opt-in variant, which for claude also carries the ``Read``
|
||||
built-in — a different toolset is a different agent, so it is a different class with
|
||||
its own name rather than a flag on the canonical one. Harnesses without a variant fall
|
||||
through to their normal class."""
|
||||
if browser:
|
||||
variant = (
|
||||
self.agent_import_path_browser
|
||||
if multi_turn
|
||||
else (self.agent_import_path_single_turn_browser or self.agent_import_path_browser)
|
||||
)
|
||||
if variant:
|
||||
return variant
|
||||
if multi_turn:
|
||||
return self.agent_import_path
|
||||
return self.agent_import_path_single_turn or self.agent_import_path
|
||||
|
||||
def row_label(self, model: str) -> str:
|
||||
"""Row identity for one trial: bare model for legacy harnesses (so
|
||||
published manifests keep their labels), else ``<harness>:<model>``."""
|
||||
return model if self.legacy_bare_model_rows else f"{self.id}:{model}"
|
||||
|
||||
def agent_config_overrides(self) -> dict[str, str]:
|
||||
"""``agent_config`` as flat ``dotted.key -> value`` pairs in CLI-override form.
|
||||
|
||||
Values are rendered bare — ``disabled``, not ``"disabled"``. Every consumer
|
||||
interpolates these into a shell command, which would strip the quotes anyway;
|
||||
emitting them would only make the result depend on how many shell layers the
|
||||
string crosses. Bare is what the CLIs document (``-c model="o3"`` reaches the
|
||||
binary as ``model=o3``).
|
||||
|
||||
These settings ride the command line as ``-c dotted.key=value`` everywhere the
|
||||
harness runs, never a config file. A trial sandbox rules the file out: the
|
||||
harness's own runner appends root keys to it, and TOML has no way back to the
|
||||
root scope once a table has opened, so a table we appended would swallow them.
|
||||
Overrides compose in any order and beat the file, so the same rendering serves
|
||||
the explore launcher too — one declaration, one mechanism.
|
||||
"""
|
||||
if not self.agent_config:
|
||||
return {}
|
||||
try:
|
||||
parsed = tomllib.loads(self.agent_config)
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise HarnessRegistryError(
|
||||
f"{self.id}: agent_config is not valid TOML ({exc})"
|
||||
) from exc
|
||||
|
||||
flat: dict[str, str] = {}
|
||||
|
||||
def walk(node: dict, prefix: str) -> None:
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}"
|
||||
if isinstance(value, dict):
|
||||
walk(value, f"{path}.")
|
||||
elif isinstance(value, bool):
|
||||
flat[path] = "true" if value else "false"
|
||||
elif isinstance(value, (int, float)):
|
||||
flat[path] = str(value)
|
||||
elif isinstance(value, str):
|
||||
if value != value.strip() or any(c in value for c in " \"'\\"):
|
||||
raise HarnessRegistryError(
|
||||
f"{self.id}: agent_config key {path!r} has a value needing "
|
||||
"shell quoting, which the -c override form cannot carry"
|
||||
)
|
||||
flat[path] = value
|
||||
else:
|
||||
raise HarnessRegistryError(
|
||||
f"{self.id}: agent_config key {path!r} has type "
|
||||
f"{type(value).__name__}, which has no -c override form"
|
||||
)
|
||||
|
||||
walk(parsed, "")
|
||||
return flat
|
||||
|
||||
def container_config_text(self, *, surface: str) -> str | None:
|
||||
"""Config file body for a worker container. `surface` is "explore" or
|
||||
"authoring"; explore additionally gets `explore_config`. Root keys come from
|
||||
`container_config` first, so appending a table section stays valid TOML."""
|
||||
parts = [self.container_config]
|
||||
if surface == "explore":
|
||||
parts.append(self.explore_config)
|
||||
kept = [part.strip("\n") for part in parts if part and part.strip()]
|
||||
return "\n\n".join(kept) + "\n" if kept else None
|
||||
|
||||
def agent_config_flags(self) -> str:
|
||||
"""``agent_config`` as a ``-c key=value`` command-line string."""
|
||||
return " ".join(
|
||||
f"-c {key}={value}"
|
||||
for key, value in sorted(self.agent_config_overrides().items())
|
||||
)
|
||||
|
||||
def explore_launch_command(self) -> str | None:
|
||||
"""``explore_launch`` with the registry's own values substituted in.
|
||||
|
||||
The worker's Explore session and the trial must run the same agent, so the
|
||||
model, effort and reductions are declared once here and rendered into both.
|
||||
A literal in the launch string would be a second declaration, and the two
|
||||
would drift the first time one of them was updated alone.
|
||||
|
||||
Only these three placeholders are substituted; ``$@`` and
|
||||
``$RACCOON_TOOLSET_NOTE`` stay for the launcher's own shell to expand.
|
||||
"""
|
||||
if not self.explore_launch:
|
||||
return None
|
||||
return (
|
||||
self.explore_launch.replace("$RACCOON_AGENT_FLAGS", self.agent_config_flags())
|
||||
.replace("$RACCOON_MODEL", self.default_model or "")
|
||||
.replace("$RACCOON_EFFORT", self.effort_default or "")
|
||||
)
|
||||
|
||||
def known_import_paths(self) -> tuple[str, ...]:
|
||||
paths = [self.agent_import_path, *self.import_path_aliases]
|
||||
if self.agent_import_path_single_turn:
|
||||
paths.append(self.agent_import_path_single_turn)
|
||||
return tuple(paths)
|
||||
|
||||
|
||||
_KNOWN_FIELDS = frozenset(
|
||||
{
|
||||
"id",
|
||||
"label",
|
||||
"agent_import_path",
|
||||
"agent_import_path_single_turn",
|
||||
"agent_import_path_browser",
|
||||
"agent_import_path_single_turn_browser",
|
||||
"import_path_aliases",
|
||||
"legacy_bare_model_rows",
|
||||
"default_model",
|
||||
"model_id_shape",
|
||||
"effort_kwarg",
|
||||
"effort_default",
|
||||
"fast_kwarg",
|
||||
"key_env",
|
||||
"base_url_env",
|
||||
"proxy_path",
|
||||
"writes_atif",
|
||||
"capture",
|
||||
"seed_native",
|
||||
"seed_atif",
|
||||
"flaky_hangs",
|
||||
"enabled",
|
||||
"authoring",
|
||||
"cli",
|
||||
"install",
|
||||
"skills_dir",
|
||||
"auth_path",
|
||||
"auth_key_env",
|
||||
"explore_launch",
|
||||
"config_path",
|
||||
"agent_config",
|
||||
"container_config",
|
||||
"explore_config",
|
||||
}
|
||||
)
|
||||
|
||||
_REQUIRED_FIELDS = (
|
||||
"id",
|
||||
"label",
|
||||
"agent_import_path",
|
||||
"model_id_shape",
|
||||
"writes_atif",
|
||||
"capture",
|
||||
"seed_native",
|
||||
"seed_atif",
|
||||
)
|
||||
|
||||
|
||||
class HarnessRegistryError(ValueError):
|
||||
"""Malformed registry. Raised rather than tolerated: a broken registry is a
|
||||
broken deployment, and silently defaulting would pick the wrong agent."""
|
||||
|
||||
|
||||
def _references_agent_flags(launch: str) -> bool:
|
||||
return "$RACCOON_AGENT_FLAGS" in launch or "${RACCOON_AGENT_FLAGS}" in launch
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarnessRegistry:
|
||||
version: int
|
||||
harnesses: tuple[Harness, ...]
|
||||
|
||||
def all(self) -> tuple[Harness, ...]:
|
||||
return self.harnesses
|
||||
|
||||
def enabled(self) -> tuple[Harness, ...]:
|
||||
return tuple(h for h in self.harnesses if h.enabled)
|
||||
|
||||
def authoring(self) -> tuple[Harness, ...]:
|
||||
"""Harnesses a worker can author with — what the worker containers install.
|
||||
Narrower than enabled(): a harness can be runnable in a trial without having
|
||||
an authoring story (no CLI to converse with, or no capture)."""
|
||||
return tuple(h for h in self.harnesses if h.enabled and h.authoring)
|
||||
|
||||
def find(self, harness_id: str) -> Harness | None:
|
||||
return next((h for h in self.harnesses if h.id == harness_id), None)
|
||||
|
||||
def require(self, harness_id: str) -> Harness:
|
||||
harness = self.find(harness_id)
|
||||
if harness is not None:
|
||||
return harness
|
||||
available = ", ".join(sorted(h.id for h in self.enabled()))
|
||||
raise HarnessRegistryError(
|
||||
f'Unknown harness "{harness_id}". Available: {available}'
|
||||
)
|
||||
|
||||
def by_import_path(self, agent: str) -> Harness | None:
|
||||
"""Resolve an agent identity — a ``name()`` or import path from
|
||||
``result.json`` ``config.agent``, or a manifest row — to its harness."""
|
||||
needle = (agent or "").strip()
|
||||
if not needle:
|
||||
return None
|
||||
for harness in self.harnesses:
|
||||
if needle == harness.id or needle in harness.known_import_paths():
|
||||
return harness
|
||||
return None
|
||||
|
||||
|
||||
def _build(entry: dict, index: int) -> Harness:
|
||||
for name in _REQUIRED_FIELDS:
|
||||
if name not in entry:
|
||||
raise HarnessRegistryError(
|
||||
f"harness[{index}]: missing required field '{name}'"
|
||||
)
|
||||
shape = entry["model_id_shape"]
|
||||
if shape not in MODEL_ID_SHAPES:
|
||||
raise HarnessRegistryError(
|
||||
f"harness[{index}] ({entry['id']}): model_id_shape {shape!r} not one of "
|
||||
f"{sorted(MODEL_ID_SHAPES)}"
|
||||
)
|
||||
# These three reach `eval` in setup-harnesses.sh, which is how they support the
|
||||
# `${CODEX_HOME:-$HOME/.codex}` default-value syntax that python's expandvars cannot
|
||||
# express. Under eval a backtick or $( would EXECUTE, so refuse them here — the registry
|
||||
# is ours, but "ours" is not an argument that survives a careless future edit.
|
||||
for shell_field in ("config_path", "auth_path", "skills_dir"):
|
||||
value = entry.get(shell_field)
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
# A backtick or $( executes outright. A double quote closes the string these are
|
||||
# interpolated into, and a semicolon then starts a new command inside it — same
|
||||
# outcome, one step removed.
|
||||
bad = [t for t in ("`", "$(", '"', ";") if t in value]
|
||||
if bad:
|
||||
raise HarnessRegistryError(
|
||||
f"harness[{index}] ({entry['id']}): {shell_field} contains "
|
||||
f"{', '.join(repr(t) for t in bad)} ({value!r}). This value is shell-"
|
||||
f"expanded, so that would execute; use plain $VAR or ${{VAR:-default}} only."
|
||||
)
|
||||
|
||||
launch = entry.get("explore_launch")
|
||||
if entry.get("agent_config") and launch and not _references_agent_flags(launch):
|
||||
raise HarnessRegistryError(
|
||||
f"harness[{index}] ({entry['id']}): declares agent_config but its "
|
||||
"explore_launch does not pass $RACCOON_AGENT_FLAGS. The worker's session "
|
||||
"would then run with a different toolset than the trial it is authoring "
|
||||
"for, which is the drift agent_config exists to prevent."
|
||||
)
|
||||
return Harness(
|
||||
id=entry["id"],
|
||||
label=entry["label"],
|
||||
agent_import_path=entry["agent_import_path"],
|
||||
agent_import_path_single_turn=entry.get("agent_import_path_single_turn"),
|
||||
agent_import_path_browser=entry.get("agent_import_path_browser"),
|
||||
agent_import_path_single_turn_browser=entry.get("agent_import_path_single_turn_browser"),
|
||||
import_path_aliases=tuple(entry.get("import_path_aliases", ())),
|
||||
legacy_bare_model_rows=bool(entry.get("legacy_bare_model_rows", False)),
|
||||
default_model=entry.get("default_model"),
|
||||
model_id_shape=shape,
|
||||
effort_kwarg=entry.get("effort_kwarg", ""),
|
||||
effort_default=entry.get("effort_default"),
|
||||
fast_kwarg=entry.get("fast_kwarg", ""),
|
||||
key_env=entry.get("key_env"),
|
||||
base_url_env=entry.get("base_url_env"),
|
||||
proxy_path=entry.get("proxy_path"),
|
||||
writes_atif=bool(entry["writes_atif"]),
|
||||
capture=bool(entry["capture"]),
|
||||
seed_native=bool(entry["seed_native"]),
|
||||
seed_atif=bool(entry["seed_atif"]),
|
||||
flaky_hangs=bool(entry.get("flaky_hangs", False)),
|
||||
enabled=bool(entry.get("enabled", True)),
|
||||
authoring=bool(entry.get("authoring", False)),
|
||||
cli=entry.get("cli"),
|
||||
install=entry.get("install"),
|
||||
skills_dir=entry.get("skills_dir"),
|
||||
auth_path=entry.get("auth_path"),
|
||||
auth_key_env=entry.get("auth_key_env"),
|
||||
explore_launch=entry.get("explore_launch"),
|
||||
config_path=entry.get("config_path"),
|
||||
agent_config=entry.get("agent_config"),
|
||||
container_config=entry.get("container_config"),
|
||||
explore_config=entry.get("explore_config"),
|
||||
extra={k: v for k, v in entry.items() if k not in _KNOWN_FIELDS},
|
||||
)
|
||||
|
||||
|
||||
_cache: dict[Path, HarnessRegistry] = {}
|
||||
|
||||
|
||||
def load_harness_registry(path: Path | str = REGISTRY_PATH) -> HarnessRegistry:
|
||||
"""Parse and validate the registry. Raises HarnessRegistryError on a malformed
|
||||
file, a duplicate id, or an import path claimed by two harnesses (which would
|
||||
make ``by_import_path`` depend on declaration order)."""
|
||||
resolved = Path(path).resolve()
|
||||
if resolved in _cache:
|
||||
return _cache[resolved]
|
||||
|
||||
with open(resolved, "rb") as handle:
|
||||
doc = tomllib.load(handle)
|
||||
|
||||
if "version" not in doc:
|
||||
raise HarnessRegistryError("harness-registry: missing 'version'")
|
||||
entries = doc.get("harness") or []
|
||||
if not entries:
|
||||
raise HarnessRegistryError("harness-registry: no [[harness]] entries")
|
||||
|
||||
harnesses = tuple(_build(entry, i) for i, entry in enumerate(entries))
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
for harness in harnesses:
|
||||
if harness.id in seen_ids:
|
||||
raise HarnessRegistryError(
|
||||
f"harness-registry: duplicate harness id: {harness.id}"
|
||||
)
|
||||
seen_ids.add(harness.id)
|
||||
|
||||
owners: dict[str, str] = {}
|
||||
for harness in harnesses:
|
||||
for import_path in harness.known_import_paths():
|
||||
owner = owners.get(import_path)
|
||||
if owner is not None and owner != harness.id:
|
||||
raise HarnessRegistryError(
|
||||
f'harness-registry: import path "{import_path}" claimed by both '
|
||||
f'"{owner}" and "{harness.id}"'
|
||||
)
|
||||
owners[import_path] = harness.id
|
||||
|
||||
registry = HarnessRegistry(version=int(doc["version"]), harnesses=harnesses)
|
||||
_cache[resolved] = registry
|
||||
return registry
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Rewrite the auth FILES harnesses read their key from — and the base URL beside them —
|
||||
# off the live .env, then exec "$@".
|
||||
#
|
||||
# codex reads its key from ${CODEX_HOME:-$HOME/.codex}/auth.json, which container-create
|
||||
# wrote once from the .env of that moment — so a key rotated afterwards never reached it
|
||||
# and needed a rebuild. claude needs none of this: it has an apiKeyHelper that re-reads
|
||||
# .env per request. Interactive launches route through here so each one re-derives first.
|
||||
#
|
||||
# The base URL never rotates, so the case that matters is the one where container-create
|
||||
# could not derive it at all (no .env yet) and wrote no config: the key then refreshes
|
||||
# fine while codex still has no proxy URL and talks to the provider directly.
|
||||
#
|
||||
# Trials are unaffected either way: harbor-run re-derives OPENAI_API_KEY per invocation
|
||||
# and harbor's codex agent authenticates the sandbox from that env var, not from this file.
|
||||
set -uo pipefail
|
||||
|
||||
_scripts_dir="${HARNESS_SCRIPTS_DIR:-/workspace/scripts}"
|
||||
|
||||
# Subshell, and every failure swallowed: a refresh that cannot run must never stop the
|
||||
# agent from starting. The auth file already on disk is the PREVIOUS key, not nothing, so
|
||||
# failing open leaves the worker exactly where they were before this wrapper existed.
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${RACCOON_ENV_FILE:-/workspace/.env}" 2>/dev/null || true
|
||||
set +a
|
||||
# shellcheck disable=SC1091
|
||||
HARNESS_SCRIPTS_DIR="$_scripts_dir" . "$_scripts_dir/lib/harness-credentials.sh" || exit 0
|
||||
harness_setup_credentials
|
||||
harness_write_auth
|
||||
harness_refresh_config_keys
|
||||
) >/dev/null 2>&1 || true
|
||||
|
||||
# No args is a valid call: refresh only, for a lifecycle hook.
|
||||
[ "$#" -gt 0 ] || exit 0
|
||||
|
||||
# Outside the subshell, because these have to reach the exec'd command: codex now reads
|
||||
# its key from $ANTHROPIC_API_KEY per request, and a non-login shell sourced neither
|
||||
# .bashrc (the key, the call origin) nor the profile that puts the CLI on PATH.
|
||||
# Failures stay swallowed — an unreadable .env must not stop the agent starting.
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
if [ -f "${RACCOON_ENV_FILE:-/workspace/.env}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${RACCOON_ENV_FILE:-/workspace/.env}" 2>/dev/null || true
|
||||
set +a
|
||||
fi
|
||||
if [ -f "$HOME/.raccoon-call-origin" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. "$HOME/.raccoon-call-origin" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -1,459 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""resolve_harness.py — turn a harness id + task dir into the flags a trial needs.
|
||||
|
||||
``scripts/harbor-run`` is bash and cannot parse the TOML registry, so it shells in
|
||||
here and evals the result::
|
||||
|
||||
RESOLVED="$(python3 scripts/resolve_harness.py --task-dir "$TASK_DIR")" || exit 1
|
||||
eval "$RESOLVED"
|
||||
|
||||
Python rather than TS on purpose: this ships in the worker toolkit, whose
|
||||
package.json has no ``zod``/``smol-toml``, and ``tomllib`` is stdlib — so the
|
||||
toolkit gains a harness-aware harbor-run with zero new dependencies. There is no TS
|
||||
loader: TS callers (submit-task) shell in here, so both the schema and the selection
|
||||
policy exist exactly once and there is nothing to drift.
|
||||
|
||||
Output is POSIX ``KEY='value'`` assignments (single-quoted, embedded quotes
|
||||
escaped) on stdout; everything human-facing goes to stderr, so the eval only ever
|
||||
sees assignments. A non-zero exit means "do not launch" — the point is to fail in a
|
||||
second rather than burn agent minutes on a trial that cannot produce a usable grade.
|
||||
|
||||
Refuses to resolve when:
|
||||
- the harness id is unknown or disabled
|
||||
- the harness writes no ATIF trajectory (the grader would have no transcript)
|
||||
- the task ships a session to resume but the harness cannot resume one. This is
|
||||
the important one: it is the only failure here that would otherwise look like
|
||||
SUCCESS, with the agent answering a prompt whose conversation it never saw.
|
||||
- the harness's credential env var is unset
|
||||
|
||||
``--check-model`` additionally asks the proxy whether the model is granted. Opt-in
|
||||
on purpose: it is a network call, and one in every run's critical path trades a fast
|
||||
local failure for a new way to hang. The credential check, which is free, always runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
|
||||
|
||||
from harness_registry import ( # noqa: E402
|
||||
Harness,
|
||||
HarnessRegistryError,
|
||||
load_harness_registry,
|
||||
)
|
||||
|
||||
# Harness used when nothing selects one. Keeps every existing caller on today's
|
||||
# behaviour, so adding harness selection changes no current run.
|
||||
DEFAULT_HARNESS = "claude-code"
|
||||
|
||||
MODELS_TIMEOUT_SEC = 20
|
||||
|
||||
|
||||
def warn(message: str) -> None:
|
||||
print(f"resolve-harness: {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def fail(message: str) -> "None":
|
||||
print(f"resolve-harness: ERROR: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def is_multi_turn(task_dir: str | None) -> bool:
|
||||
"""A task is multi-turn when it ships a NON-EMPTY session to resume. Empty is
|
||||
the documented one-shot-snapshot fallback and must run cold, so size is the
|
||||
test, not existence."""
|
||||
if not task_dir:
|
||||
return False
|
||||
session = Path(task_dir) / "environment" / "session.jsonl"
|
||||
return session.is_file() and session.stat().st_size > 0
|
||||
|
||||
|
||||
def wants_browser(task_dir: str | None) -> bool:
|
||||
"""True when task.toml opts into a browser (`[metadata] browser = true`).
|
||||
|
||||
Read straight from the file rather than via tomllib: this must agree with
|
||||
build-workspace.sh, which decides whether the IMAGE gets Playwright using the same
|
||||
text match. If the two ever disagree the agent is told about a browser the image
|
||||
lacks, which is the one failure the disclosure is designed to make impossible.
|
||||
Accepts the quoted form for the same reason build-workspace.sh does."""
|
||||
if not task_dir:
|
||||
return False
|
||||
toml_path = Path(task_dir) / "task.toml"
|
||||
if not toml_path.is_file():
|
||||
return False
|
||||
try:
|
||||
text = toml_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
return re.search(r'^[ \t]*browser[ \t]*=[ \t]*"?true"?[ \t]*$', text, re.M) is not None
|
||||
|
||||
|
||||
def harness_from_task_toml(task_dir: str | None) -> str | None:
|
||||
"""The task's own `[agent] harness` — the authoritative record of which harness
|
||||
this task was authored against.
|
||||
|
||||
This is where the worker's choice lands: the snapshot flow stamps it from the CLI
|
||||
that produced the snapshot, and a manual author writes it themselves. Either way
|
||||
it is set at task-creation time, BEFORE any trial, so nothing here depends on a
|
||||
trial's output.
|
||||
|
||||
Parsed with tomllib rather than a grep: a regex would happily match a commented
|
||||
line or the wrong table, and picking the wrong harness is a silent
|
||||
wrong-agent-runs bug.
|
||||
|
||||
Returns None when the field is simply absent — the normal case for every task
|
||||
finalized before harness selection existed — so the caller falls through to the
|
||||
toolkit default.
|
||||
|
||||
But an UNPARSEABLE task.toml refuses outright rather than falling back. Those are
|
||||
different situations and treating them alike is how the wrong harness runs
|
||||
quietly: the most likely way to break this file is adding a second `[agent]`
|
||||
table instead of a `harness` line inside the existing one (tasks already carry
|
||||
`[agent] timeout_sec`), and TOML rejects a duplicate table. Falling back there
|
||||
would run claude against a task its author wrote for codex and grade it as if
|
||||
nothing were wrong.
|
||||
"""
|
||||
if not task_dir:
|
||||
return None
|
||||
path = Path(task_dir) / "task.toml"
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
doc = tomllib.load(handle)
|
||||
except (OSError, tomllib.TOMLDecodeError) as exc:
|
||||
fail(
|
||||
f"{path} could not be parsed ({exc}). Refusing to guess a harness — fix "
|
||||
f'the file. If you were adding a harness, put `harness = "..."` inside '
|
||||
f"the EXISTING [agent] table rather than starting a second one."
|
||||
)
|
||||
harness = (doc.get("agent") or {}).get("harness")
|
||||
return harness if isinstance(harness, str) and harness else None
|
||||
|
||||
|
||||
def normalize_model(harness: Harness, model: str) -> str:
|
||||
"""Model id on the wire, per the harness's declared shape."""
|
||||
if harness.model_id_shape == "provider:model":
|
||||
return model.replace("/", ":")
|
||||
return model
|
||||
|
||||
|
||||
def granted_models(harness: Harness) -> list[str] | None:
|
||||
"""Model ids the key is granted, or None when the check couldn't run."""
|
||||
base_url = os.environ.get(harness.base_url_env or "")
|
||||
key = os.environ.get(harness.key_env or "")
|
||||
if not base_url or not key:
|
||||
warn("--check-model skipped: base URL or key env is unset")
|
||||
return None
|
||||
request = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/models", headers={"Authorization": f"Bearer {key}"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=MODELS_TIMEOUT_SEC) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as exc:
|
||||
warn(f"--check-model skipped: /models unreachable ({exc})")
|
||||
return None
|
||||
return [m["id"] for m in body.get("data", []) if isinstance(m.get("id"), str)]
|
||||
|
||||
|
||||
def assert_model_granted(harness: Harness, model: str) -> None:
|
||||
granted = granted_models(harness)
|
||||
if granted is None:
|
||||
return
|
||||
# The proxy LISTS ids provider-prefixed ("openai/gpt-5.6-sol") but 400s on that
|
||||
# form — requests take the bare id. Accept either spelling.
|
||||
bare = {g.split("/")[-1] for g in granted}
|
||||
if model not in granted and model not in bare:
|
||||
shown = ", ".join(granted[:12]) + (", …" if len(granted) > 12 else "")
|
||||
fail(
|
||||
f'Model "{model}" is not granted for this key. Granted ({len(granted)}): {shown}'
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--harness",
|
||||
help=f"harness id (default: the task's [agent] harness, else {DEFAULT_HARNESS})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task-dir",
|
||||
help="task directory; decides multi-turn from environment/session.jsonl",
|
||||
)
|
||||
parser.add_argument("--model", help="override the harness's default model")
|
||||
parser.add_argument(
|
||||
"--fast",
|
||||
action="store_true",
|
||||
help="run the trial agent in the harness's fast serving mode (higher token "
|
||||
"rate, faster output). Refuses on a harness that has none.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-model",
|
||||
action="store_true",
|
||||
help="also ask the proxy whether the model is granted (network call)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--authoring-installs",
|
||||
action="store_true",
|
||||
help="print '<id>\\t<cli>\\t<install>' for each harness a worker can author "
|
||||
"with, and exit. Consumed by scripts/setup-harnesses.sh so the worker "
|
||||
"containers install from the registry rather than from hardcoded lists that "
|
||||
"drift.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--container-configs",
|
||||
action="store_true",
|
||||
help="print '<id>\\t<config_path>\\t<base64 container_config>' for each "
|
||||
"authoring harness that declares one, and exit. Base64 because the config is "
|
||||
"multi-line TOML and these query modes are line-oriented.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--surface",
|
||||
choices=("authoring", "explore"),
|
||||
default="authoring",
|
||||
help="which worker container --container-configs is for; explore additionally "
|
||||
"gets the capture hooks, whose commands only ship there.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--defaults",
|
||||
action="store_true",
|
||||
help="print '<id>\\t<default_model>\\t<effort_default>' for every harness, and "
|
||||
"exit. For recording what a task was authored against; nothing reads it back.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--explore-launchers",
|
||||
action="store_true",
|
||||
help="print '<id>\\t<cli>\\t<launch command>' for each authoring harness, and "
|
||||
"exit. The launch command has the registry's model, effort and agent_config "
|
||||
"already substituted, so Explore and a trial cannot disagree about them. "
|
||||
"Consumed by setup-harnesses.sh to write one launcher per harness.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills-dirs",
|
||||
action="store_true",
|
||||
help="print '<id>\t<skills_dir>' for each authoring harness that discovers "
|
||||
"skills from a directory, and exit. Lets setup-harnesses.sh install the "
|
||||
"snapshot skill for harnesses that have no plugin system.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auth-files",
|
||||
action="store_true",
|
||||
help="print '<id>\t<auth_path>\t<auth_key_env>' for each authoring harness that "
|
||||
"authenticates from a file rather than the environment, and exit.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--authoring-credentials",
|
||||
action="store_true",
|
||||
help="print '<id>\\t<key_env>\\t<base_url_env>\\t<proxy_path>' for each "
|
||||
"authoring harness, and exit. Lets the containers point every harness at the "
|
||||
"same proxy key on its own provider path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--declared-harness",
|
||||
action="store_true",
|
||||
help="print ONLY the harness --task-dir's task.toml declares (empty if it "
|
||||
"declares none) and exit. Unlike the default mode this applies no fallback, so "
|
||||
"a caller can tell 'declared' from 'defaulted'. Exists so consumers without a "
|
||||
"TOML parser never hand-roll one: a regex would match a commented line or the "
|
||||
"wrong table, and the duplicate-[agent] shape is exactly the likely mistake.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resolve-identity",
|
||||
action="append",
|
||||
default=None,
|
||||
metavar="AGENT",
|
||||
help="resolve agent identities (a result.json config.agent import_path or name) "
|
||||
"to harness ids and exit; repeatable. Prints one TAB-separated "
|
||||
"'<identity>\\t<harness-id>' line each, with an empty id when nothing claims it. "
|
||||
"Lets callers that cannot import the registry (the worker toolkit has no "
|
||||
"zod/smol-toml) still resolve through the one source of truth.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="print the selectable harnesses and exit (what task.toml's [agent] harness accepts)",
|
||||
)
|
||||
parser.add_argument("--registry", default=None, help="registry path (tests)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
registry = (
|
||||
load_harness_registry(args.registry)
|
||||
if args.registry
|
||||
else load_harness_registry()
|
||||
)
|
||||
except HarnessRegistryError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
# --- read-only query modes: answer and exit, never emit assignments -------
|
||||
if args.authoring_installs:
|
||||
for harness in registry.authoring():
|
||||
print(f"{harness.id}\t{harness.cli or ''}\t{harness.install or ''}")
|
||||
return 0
|
||||
|
||||
if args.container_configs:
|
||||
import base64
|
||||
|
||||
for harness in registry.authoring():
|
||||
config = harness.container_config_text(surface=args.surface)
|
||||
if not (harness.config_path and config):
|
||||
continue
|
||||
blob = base64.b64encode(config.encode()).decode()
|
||||
print(f"{harness.id}\t{harness.config_path}\t{blob}")
|
||||
return 0
|
||||
|
||||
if args.defaults:
|
||||
for harness in registry.all():
|
||||
print(
|
||||
f"{harness.id}\t{harness.default_model or ''}\t"
|
||||
f"{harness.effort_default or ''}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.explore_launchers:
|
||||
for harness in registry.authoring():
|
||||
print(
|
||||
f"{harness.id}\t{harness.cli or ''}\t"
|
||||
f"{harness.explore_launch_command() or ''}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.skills_dirs:
|
||||
for harness in registry.authoring():
|
||||
if harness.skills_dir:
|
||||
print(f"{harness.id}\t{harness.skills_dir}")
|
||||
return 0
|
||||
|
||||
if args.auth_files:
|
||||
for harness in registry.authoring():
|
||||
if harness.auth_path and harness.auth_key_env:
|
||||
print(f"{harness.id}\t{harness.auth_path}\t{harness.auth_key_env}")
|
||||
return 0
|
||||
|
||||
if args.authoring_credentials:
|
||||
for harness in registry.authoring():
|
||||
print(
|
||||
f"{harness.id}\t{harness.key_env or ''}\t"
|
||||
f"{harness.base_url_env or ''}\t{harness.proxy_path or ''}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.declared_harness:
|
||||
print(harness_from_task_toml(args.task_dir) or "")
|
||||
return 0
|
||||
|
||||
if args.resolve_identity:
|
||||
for identity in args.resolve_identity:
|
||||
harness = registry.by_import_path(identity)
|
||||
print(f"{identity}\t{harness.id if harness else ''}")
|
||||
return 0
|
||||
|
||||
if args.list:
|
||||
# Printed on stdout because it is the requested output here, not the
|
||||
# eval-able assignments — this mode is for a human, and never shelled into.
|
||||
for harness in registry.enabled():
|
||||
turns = (
|
||||
"multi-turn + single-turn"
|
||||
if harness.seed_native
|
||||
else "single-turn only"
|
||||
)
|
||||
model = harness.default_model or "(pass --model)"
|
||||
print(f"{harness.id:<16} {harness.label:<20} {turns:<24} {model}")
|
||||
return 0
|
||||
|
||||
# --- selection ------------------------------------------------------------
|
||||
# Precedence: an explicit --harness (a benchmark, or a deliberate override) beats the
|
||||
# task's own record, which is what its author chose. Everything else — every task
|
||||
# finalized before harness selection existed — is the default.
|
||||
requested = args.harness or harness_from_task_toml(args.task_dir) or DEFAULT_HARNESS
|
||||
try:
|
||||
harness = registry.require(requested)
|
||||
except HarnessRegistryError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
if not harness.enabled:
|
||||
fail(
|
||||
f'Harness "{harness.id}" is disabled in the registry (never verified here). '
|
||||
f"Enable it in scripts/harness-registry.toml once a trial has been run with it."
|
||||
)
|
||||
if not harness.writes_atif:
|
||||
fail(
|
||||
f'Harness "{harness.id}" writes no ATIF trajectory, so the grader would have '
|
||||
f"no transcript and its rewards would be meaningless."
|
||||
)
|
||||
|
||||
multi_turn = is_multi_turn(args.task_dir)
|
||||
if multi_turn and not harness.seed_native:
|
||||
fail(
|
||||
f'Task ships a session to resume, but harness "{harness.id}" cannot resume '
|
||||
f"one. Running anyway would look like a success while the agent answered a "
|
||||
f"prompt whose conversation it never saw."
|
||||
)
|
||||
|
||||
if harness.key_env and not os.environ.get(harness.key_env):
|
||||
fail(f'{harness.key_env} is unset — required by harness "{harness.id}".')
|
||||
|
||||
if args.fast and not harness.fast_kwarg:
|
||||
fail(
|
||||
f'Harness "{harness.id}" has no fast serving mode (no fast_kwarg in the '
|
||||
f"registry). Drop --fast or pick a harness that declares one."
|
||||
)
|
||||
|
||||
model = args.model or harness.default_model
|
||||
if not model:
|
||||
fail(
|
||||
f'Harness "{harness.id}" has no default_model in the registry; pass --model '
|
||||
f"explicitly."
|
||||
)
|
||||
if args.check_model:
|
||||
assert_model_granted(harness, model)
|
||||
|
||||
# Every assignment here becomes a harbor flag. Nothing else: the caller is bash, and
|
||||
# anything it would only echo back at the worker is said below instead.
|
||||
browser = wants_browser(args.task_dir)
|
||||
assignments = {
|
||||
"AGENT_IMPORT_PATH": harness.agent_import_path_for(multi_turn=multi_turn, browser=browser),
|
||||
"MODEL": normalize_model(harness, model),
|
||||
"EFFORT_KWARG": harness.effort_kwarg,
|
||||
"EFFORT_VALUE": (harness.effort_default or "") if harness.effort_kwarg else "",
|
||||
"FAST_KWARG": harness.fast_kwarg if args.fast else "",
|
||||
}
|
||||
|
||||
warn(
|
||||
f"{harness.label} · model={assignments['MODEL']} · "
|
||||
f"{'multi-turn' if multi_turn else 'single-turn'} · "
|
||||
f"{'browser · ' if browser else ''}"
|
||||
f"{'fast · ' if args.fast else ''}"
|
||||
f"agent={assignments['AGENT_IMPORT_PATH']}"
|
||||
)
|
||||
if browser and not harness.agent_import_path_browser:
|
||||
# Not a failure: the image still gets Playwright and the agent is still told about
|
||||
# it. Only the Read-enabled toolset swap is claude-specific, and saying so beats
|
||||
# letting someone infer from a log line that the opt-in was ignored entirely.
|
||||
warn(
|
||||
f'"{harness.id}" has no browser-specific agent, so it runs its usual toolset. '
|
||||
f"The browser and its disclosure are unaffected."
|
||||
)
|
||||
if harness.flaky_hangs:
|
||||
warn(
|
||||
f"{harness.label} is known to hang with no client-side timeout on a small "
|
||||
f"fraction of trials. A silent, output-less trial is that, not a task defect."
|
||||
)
|
||||
for key, value in assignments.items():
|
||||
print(f"{key}={shlex.quote(value)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,353 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Install the harnesses a worker can author with, from scripts/harness-registry.toml.
|
||||
#
|
||||
# Source it, then call unpiped — it exports credentials, which a subshell would lose:
|
||||
#
|
||||
# . /workspace/scripts/setup-harnesses.sh
|
||||
# harness_setup_all
|
||||
#
|
||||
# Registry reading and credential derivation live in lib/harness-credentials.sh, sourced
|
||||
# below, because `harbor-run` needs those and nothing else here.
|
||||
#
|
||||
# No -e here — but this file is SOURCED, and shell options belong to the caller's shell:
|
||||
# both post-creates run with -e, so that is what is in force. An unguarded failure below
|
||||
# therefore aborts container creation, which is why every failure site is individually
|
||||
# guarded (`|| true`, `if !`) rather than relying on this line.
|
||||
set -uo pipefail
|
||||
|
||||
_HARNESS_REGISTRY_DIR="${HARNESS_SCRIPTS_DIR:-/workspace/scripts}"
|
||||
if [ ! -f "$_HARNESS_REGISTRY_DIR/lib/harness-credentials.sh" ]; then
|
||||
echo "harness-setup: FATAL — $_HARNESS_REGISTRY_DIR/lib/harness-credentials.sh is" >&2
|
||||
echo "harness-setup: missing, so nothing here can read the registry. Every step below" >&2
|
||||
echo "harness-setup: would report a missing interpreter instead of this." >&2
|
||||
return 1 2>/dev/null || exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
. "$_HARNESS_REGISTRY_DIR/lib/harness-credentials.sh"
|
||||
|
||||
# Every setup step reads the registry through _harness_query, and each call suppresses
|
||||
# stderr so one bad row can't abort the container. That means a BROKEN interpreter turns
|
||||
# the whole of setup into a silent no-op: no credentials, no CLIs, no config, no
|
||||
# launchers, and no error anywhere. Check it once, loudly, before any of that.
|
||||
harness_preflight() {
|
||||
local err py found=yes
|
||||
py=$(_raccoon_python) || { py=python3; found=no; }
|
||||
if ! err=$("$py" "$_HARNESS_REGISTRY_DIR/resolve_harness.py" --list 2>&1 >/dev/null); then
|
||||
echo "harness-setup: FATAL — cannot read the harness registry, so no agent CLI" >&2
|
||||
echo "harness-setup: would be installed. Nothing below will run." >&2
|
||||
echo "harness-setup: interpreter: $(command -v "$py" || echo MISSING) ($("$py" -V 2>&1))" >&2
|
||||
if [ "$found" = no ]; then
|
||||
echo "harness-setup: no python3.11+ with tomllib found; set RACCOON_PYTHON to override" >&2
|
||||
fi
|
||||
echo "harness-setup: registry: $_HARNESS_REGISTRY_DIR/harness-registry.toml" >&2
|
||||
printf 'harness-setup: %s\n' "$err" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# claude installs into $HOME/.local/bin, which is not on PATH during post-create.
|
||||
case ":$PATH:" in
|
||||
*":$HOME/.local/bin:"*) ;;
|
||||
*) export PATH="$HOME/.local/bin:$PATH" ;;
|
||||
esac
|
||||
|
||||
# --- installs ----------------------------------------------------------------
|
||||
harness_install_clis() {
|
||||
local id cli install
|
||||
while IFS=$'\t' read -r id cli install; do
|
||||
[ -n "$install" ] || continue
|
||||
if command -v "$cli" >/dev/null 2>&1; then
|
||||
echo "harness-setup: $cli already installed — skipping" >&2
|
||||
continue
|
||||
fi
|
||||
echo "harness-setup: installing $id ($cli)" >&2
|
||||
# Reported as unavailable below rather than fatal.
|
||||
if ! bash -c "$install" >&2; then
|
||||
echo "harness-setup: WARNING $id failed to install — $cli will be unavailable" >&2
|
||||
fi
|
||||
done < <(_harness_query --authoring-installs 2>/dev/null || true)
|
||||
}
|
||||
|
||||
# Report which CLIs are usable. Non-zero when NONE are: one harness missing is survivable
|
||||
# (a worker uses the other), but zero means the container cannot author anything at all,
|
||||
# and that must stop setup rather than read as a couple of warnings.
|
||||
harness_report() {
|
||||
local id cli install ready=0 missing=0
|
||||
while IFS=$'\t' read -r id cli install; do
|
||||
[ -n "$cli" ] || continue
|
||||
if command -v "$cli" >/dev/null 2>&1; then
|
||||
echo " $cli — ready" >&2
|
||||
ready=$((ready + 1))
|
||||
else
|
||||
echo " $cli — NOT AVAILABLE (install failed; see above)" >&2
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
done < <(_harness_query --authoring-installs 2>/dev/null || true)
|
||||
|
||||
# A CLI on PATH with no key is worse than a missing one: it starts, then fails at the
|
||||
# first request with the harness's own auth error, which says nothing about setup.
|
||||
local id key_env base_url_env proxy_path
|
||||
while IFS=$'\t' read -r id key_env base_url_env proxy_path; do
|
||||
[ -n "$key_env" ] || continue
|
||||
if [ -z "${!key_env:-}" ]; then
|
||||
echo " $id — installed but NO CREDENTIALS: $key_env is unset." >&2
|
||||
echo " Derived from ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY; set both in .env." >&2
|
||||
fi
|
||||
done < <(_harness_query --authoring-credentials 2>/dev/null || true)
|
||||
|
||||
if [ "$ready" -eq 0 ]; then
|
||||
echo "harness-setup: FATAL — no agent CLI installed ($missing attempted)." >&2
|
||||
echo "harness-setup: This container cannot author a task. Check the install" >&2
|
||||
echo "harness-setup: output above: the CLIs download over the network, so a" >&2
|
||||
echo "harness-setup: proxy, DNS or upstream change breaks every one at once." >&2
|
||||
return 1
|
||||
fi
|
||||
[ "$missing" -gt 0 ] && echo "harness-setup: $missing harness(es) unavailable; $ready usable" >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Explore launchers -------------------------------------------------------
|
||||
# One `raccoon-explore-<cli>` per harness, aliased to its `cli`.
|
||||
harness_install_launchers() {
|
||||
local bin="$HOME/.local/bin"
|
||||
mkdir -p "$bin"
|
||||
# Read at launcher run time so the note stays a file, not a baked-in copy.
|
||||
local note_src="${HARNESS_TOOLSET_NOTE:-/workspace/scripts/toolset_note.md}"
|
||||
local browser_note_src="${note_src%.md}_browser.md"
|
||||
local read_note_src="${note_src%.md}_read.md"
|
||||
local agent_cli_dir="${AGENT_CLI_DIR:-/opt/agent-cli}"
|
||||
|
||||
# Which harnesses keep their key in a file rather than reading $ENV per request. Those
|
||||
# launchers refresh it first: the file dates from container create, so a key rotated in
|
||||
# .env since then would otherwise reach the harness only after a rebuild.
|
||||
local file_auth_ids="" aid apath akey
|
||||
while IFS=$'\t' read -r aid apath akey; do
|
||||
[ -n "$apath" ] || continue
|
||||
file_auth_ids="${file_auth_ids:+$file_auth_ids }$aid"
|
||||
done < <(_harness_query --auth-files 2>/dev/null || true)
|
||||
|
||||
local id cli launch switchable refresh_line
|
||||
while IFS=$'\t' read -r id cli launch; do
|
||||
[ -n "$cli" ] && [ -n "$launch" ] || continue
|
||||
# `|| true` twice over (here and inside the script): the launcher runs under
|
||||
# `set -e`, and a failed refresh must not cost the worker their agent.
|
||||
if [[ " $file_auth_ids " == *" $id "* ]]; then
|
||||
refresh_line="\"$_HARNESS_REGISTRY_DIR/refresh-harness-auth\" || true"
|
||||
else
|
||||
refresh_line=""
|
||||
fi
|
||||
# Whether RACCOON_BROWSER_TASK can change THIS harness's toolset, read off the
|
||||
# registry rather than hardcoded: a launch line that interpolates $RACCOON_TOOLS
|
||||
# can, and one that doesn't cannot. codex is the second case — it ships view_image,
|
||||
# so a browser task needs nothing added and the flag has nothing to switch.
|
||||
# Match the whole variable name: a substring test also hits RACCOON_TOOLSET_NOTE,
|
||||
# which every launch line references, and every harness would look switchable.
|
||||
if [[ "$launch" =~ \$\{?RACCOON_TOOLS\}?([^A-Za-z0-9_]|$) ]]; then
|
||||
switchable=1
|
||||
else
|
||||
switchable=0
|
||||
fi
|
||||
cat > "$bin/raccoon-explore-$cli" <<LAUNCHER
|
||||
#!/bin/bash
|
||||
# GENERATED by scripts/setup-harnesses.sh from harness-registry.toml — do not edit.
|
||||
set -euo pipefail
|
||||
# A harness that reads its key from \$ENV per request needs .env in its environment,
|
||||
# and only an interactive shell sources .bashrc — which is also where PATH picks up
|
||||
# ~/.local/bin, where the CLI itself lives. Both are set here so a launch works the
|
||||
# same either way, with the key .env holds right now.
|
||||
export PATH="\$HOME/.local/bin:\$PATH"
|
||||
if [ -f "\${RACCOON_ENV_FILE:-/workspace/.env}" ]; then
|
||||
set -a
|
||||
. "\${RACCOON_ENV_FILE:-/workspace/.env}"
|
||||
set +a
|
||||
fi
|
||||
if [ -f "\$HOME/.raccoon-call-origin" ]; then
|
||||
. "\$HOME/.raccoon-call-origin"
|
||||
fi
|
||||
if [ -f "$note_src" ]; then
|
||||
RACCOON_TOOLSET_NOTE="\$(sed "s#/opt/agent-cli#$agent_cli_dir#g" "$note_src")"
|
||||
else
|
||||
RACCOON_TOOLSET_NOTE=""
|
||||
fi
|
||||
# RACCOON_BROWSER_TASK=1 explores with the toolset a \`browser = true\` task runs under.
|
||||
# Named for the flag it mirrors: one word, \`browser\`, whether it's set in task.toml or
|
||||
# here. Per invocation, not per container — authoring a browser task shouldn't need a
|
||||
# rebuild, and neither should changing your mind. Default off, so ordinary exploring
|
||||
# still mirrors an ordinary trial.
|
||||
#
|
||||
# The correction must be appended AFTER the base note, which says there is no Read tool.
|
||||
RACCOON_TOOLS="Bash"
|
||||
if [ "\${RACCOON_BROWSER_TASK:-0}" = "1" ] && [ "$switchable" = "1" ] && [ -f "$read_note_src" ]; then
|
||||
RACCOON_TOOLS="Bash,Read"
|
||||
RACCOON_TOOLSET_NOTE="\${RACCOON_TOOLSET_NOTE}
|
||||
|
||||
\$(cat "$read_note_src")"
|
||||
fi
|
||||
export RACCOON_TOOLS
|
||||
# Only mention the browser on an image that actually has one — most don't. Probed at
|
||||
# launch, not baked in, so the same launcher is correct in whichever container it runs.
|
||||
#
|
||||
# Exported two ways because the harnesses take extra instructions differently: claude
|
||||
# appends the whole toolset note to --append-system-prompt, while codex has no equivalent
|
||||
# and takes -c developer_instructions=. codex must NOT get the claude-shaped toolset note
|
||||
# (it has no str_replace_editor), so the browser part is exported on its own too.
|
||||
RACCOON_BROWSER_NOTE=""
|
||||
RACCOON_BROWSER_FLAGS=()
|
||||
if command -v pw >/dev/null 2>&1 && [ -f "$browser_note_src" ]; then
|
||||
RACCOON_BROWSER_NOTE="\$(cat "$browser_note_src")"
|
||||
RACCOON_TOOLSET_NOTE="\${RACCOON_TOOLSET_NOTE}
|
||||
|
||||
\${RACCOON_BROWSER_NOTE}"
|
||||
RACCOON_BROWSER_FLAGS=(-c "developer_instructions=\${RACCOON_BROWSER_NOTE}")
|
||||
fi
|
||||
export RACCOON_TOOLSET_NOTE RACCOON_BROWSER_NOTE
|
||||
export RACCOON_HARNESS="$id"
|
||||
# These launchers exist only in explore, and a refresh that has to CREATE a config
|
||||
# needs the surface to know the capture hooks belong in it.
|
||||
export RACCOON_SURFACE=explore
|
||||
# No RACCOON_SNAPSHOT_DATA here on purpose. capture-snapshot.mjs and save-session-info.mjs
|
||||
# already share the same default ($HOME/.raccoon/snapshot-data), which is what codex needs
|
||||
# — it has no CLAUDE_PLUGIN_* to fall back to. Exporting it ALSO overrode the dir for
|
||||
# claude, whose slash command pins --plugin-data to the plugin dir, so the hook wrote one
|
||||
# place and capture read another and the recorded session was silently ignored.
|
||||
$refresh_line
|
||||
$launch
|
||||
LAUNCHER
|
||||
chmod +x "$bin/raccoon-explore-$cli"
|
||||
echo "harness-setup: launcher raccoon-explore-$cli" >&2
|
||||
done < <(_harness_query --explore-launchers 2>/dev/null || true)
|
||||
}
|
||||
|
||||
# Alias lines for ~/.bashrc.
|
||||
harness_alias_lines() {
|
||||
local id cli launch switchable
|
||||
local browser_clis=""
|
||||
while IFS=$'\t' read -r id cli launch; do
|
||||
[ -n "$cli" ] && [ -n "$launch" ] || continue
|
||||
echo "alias $cli=\"raccoon-explore-$cli\""
|
||||
# Same derivation as the launcher: only a harness whose launch line takes
|
||||
# $RACCOON_TOOLS has a toolset the flag can change.
|
||||
if [[ "$launch" =~ \$\{?RACCOON_TOOLS\}?([^A-Za-z0-9_]|$) ]]; then
|
||||
browser_clis="${browser_clis:+$browser_clis }$cli"
|
||||
fi
|
||||
done < <(_harness_query --explore-launchers 2>/dev/null || true)
|
||||
|
||||
# The browser hint belongs at the shell prompt, not in the launcher. Claude Code takes the
|
||||
# alternate screen buffer, so anything printed just before exec is hidden for the whole
|
||||
# session and resurfaces only after quitting — advice arriving exactly too late. Here it
|
||||
# lands in ordinary scrollback, before any TUI exists, and there is nothing to quit yet.
|
||||
#
|
||||
# `pw` is probed at shell start, so one ~/.bashrc is correct in a container with a browser
|
||||
# and in one without.
|
||||
[ -n "$browser_clis" ] || return 0
|
||||
local first="${browser_clis%% *}"
|
||||
cat <<HINT
|
||||
if [[ \$- == *i* ]] && [ "\${RACCOON_BROWSER_TASK:-0}" != "1" ] && command -v pw >/dev/null 2>&1; then
|
||||
echo "browser available (Playwright + Chromium, \\\`pw <script.js>\\\`)."
|
||||
echo "Authoring a \\\`browser = true\\\` task? Start it with: RACCOON_BROWSER_TASK=1 $first"
|
||||
fi
|
||||
HINT
|
||||
}
|
||||
|
||||
# Write each harness's config file from the registry, replacing whatever was there.
|
||||
#
|
||||
# The file is OWNED, not merged: TOML has no way to return to the document root after a
|
||||
# table header, so appending or prepending around foreign content silently reparents
|
||||
# root-level keys into whichever table happens to precede them. Owning it also means a
|
||||
# registry change actually reaches a container that was already set up.
|
||||
harness_write_configs() {
|
||||
local id config_path blob target tmp
|
||||
while IFS=$'\t' read -r id config_path blob; do
|
||||
[ -n "$config_path" ] && [ -n "$blob" ] || continue
|
||||
# Guarded: a bare failing assignment exits the caller's `set -e` post-create with
|
||||
# no explanation. A path this cannot expand is one harness's problem, not the
|
||||
# container's.
|
||||
target=$(eval "printf '%s' \"$config_path\"") || {
|
||||
echo "harness-setup: WARNING $id config_path could not be expanded — skipping" >&2
|
||||
continue
|
||||
}
|
||||
mkdir -p "$(dirname "$target")"
|
||||
tmp="$target.raccoon-tmp"
|
||||
# Expansion is strict: an unset var would otherwise be written through as the
|
||||
# literal ${VAR}, which surfaces much later as an unparseable value.
|
||||
if ! {
|
||||
echo "# Generated from harness-registry.toml — edits here are overwritten."
|
||||
printf '%s' "$blob" | base64 -d | python3 -c '
|
||||
import os, re, sys
|
||||
text = sys.stdin.read()
|
||||
missing = sorted(
|
||||
{m.group(1) for m in re.finditer(r"\$\{(\w+)\}", text) if m.group(1) not in os.environ}
|
||||
)
|
||||
if missing:
|
||||
sys.stderr.write("unset: " + ", ".join(missing) + "\n")
|
||||
raise SystemExit(1)
|
||||
sys.stdout.write(os.path.expandvars(text))
|
||||
'
|
||||
} > "$tmp"; then
|
||||
rm -f "$tmp"
|
||||
echo "harness-setup: WARNING $id config NOT written — a value it needs is unset." >&2
|
||||
echo "harness-setup: run harness_setup_credentials first (harness_setup_all does)." >&2
|
||||
continue
|
||||
fi
|
||||
mv "$tmp" "$target"
|
||||
echo "harness-setup: $id config -> $target" >&2
|
||||
done < <(_harness_query --container-configs --surface "${RACCOON_SURFACE:-authoring}" 2>/dev/null || true)
|
||||
}
|
||||
|
||||
# Link every available skill into each harness's skills_dir, for harnesses that declare one.
|
||||
# Both container layouts are covered: the explore container holds the snapshot skill under
|
||||
# plugins/, the authoring container holds the authoring skills under .claude/skills. Whichever
|
||||
# directories exist here are the ones this container has.
|
||||
harness_install_skills() {
|
||||
local sources="${RACCOON_SKILL_SOURCE_DIRS:-/workspace/plugins/create-snapshot/skills /workspace/.claude/skills}"
|
||||
local id dir target src skill name installed
|
||||
while IFS=$'\t' read -r id dir; do
|
||||
[ -n "$dir" ] || continue
|
||||
target=$(eval "printf '%s' \"$dir\"") || {
|
||||
echo "harness-setup: WARNING $id skills_dir could not be expanded — skipping" >&2
|
||||
continue
|
||||
}
|
||||
mkdir -p "$target"
|
||||
installed=0
|
||||
for src in $sources; do
|
||||
[ -d "$src" ] || continue
|
||||
for skill in "$src"/*/; do
|
||||
[ -f "$skill/SKILL.md" ] || continue
|
||||
name=$(basename "$skill")
|
||||
ln -sfn "${skill%/}" "$target/$name"
|
||||
installed=$((installed + 1))
|
||||
done
|
||||
done
|
||||
echo "harness-setup: $id skills -> $target ($installed linked)" >&2
|
||||
done < <(_harness_query --skills-dirs 2>/dev/null || true)
|
||||
}
|
||||
|
||||
# The lines that explain a setup failure are printed as it happens, and the devcontainer
|
||||
# CLI's own stack trace lands on top of them. Close with a banner so the worker has
|
||||
# something to look for, and something to send us.
|
||||
_harness_fatal_banner() {
|
||||
echo "" >&2
|
||||
echo " ============================================================" >&2
|
||||
echo " HARNESS SETUP FAILED — this container has no agent CLI." >&2
|
||||
echo "" >&2
|
||||
echo " The harness-setup: lines above say why. Anything the" >&2
|
||||
echo " devcontainer prints after this is a consequence, not the" >&2
|
||||
echo " cause; send us the harness-setup: lines." >&2
|
||||
echo " ============================================================" >&2
|
||||
echo "" >&2
|
||||
}
|
||||
|
||||
harness_setup_all() {
|
||||
harness_preflight || { _harness_fatal_banner; return 1; }
|
||||
harness_setup_credentials
|
||||
harness_write_auth
|
||||
harness_install_clis
|
||||
harness_write_configs
|
||||
harness_install_skills
|
||||
# Launchers are NOT installed here. They are an Explore concern (that container aliases
|
||||
# `claude`/`codex` to them), and it passes its own AGENT_CLI_DIR — installing them here
|
||||
# too wrote every launcher twice, the first time with the wrong editor path, and left an
|
||||
# unused one in the authoring container.
|
||||
echo "harness-setup: authoring harnesses" >&2
|
||||
harness_report || { _harness_fatal_banner; return 1; }
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""str_replace_editor — CLI-as-MCP wrapper around the vendored EditTool.
|
||||
|
||||
This is the "CLI-as-MCP" delivery of the `str_replace_editor` tool: the agent
|
||||
(which has ONLY the bash tool) invokes this script and passes the tool's
|
||||
arguments as one JSON object on stdin. The actual editing logic is the vendored
|
||||
`EditTool` under str_replace_editor_vendor/ (see VENDORED.md) — we add no
|
||||
behavior, we only:
|
||||
* instantiate it with run_command_preexec_fn=None (the class's own documented
|
||||
way to skip its uid/gid-1000 demotion, which would break writes in our
|
||||
sandbox where the workspace is owned by the agent user); and
|
||||
* adapt structured stdin-JSON <-> a bash-invokable CLI.
|
||||
|
||||
stdin: one JSON object, e.g.
|
||||
{"command":"view","path":"/workspace/app/models/x.rb"}
|
||||
{"command":"view","path":"/workspace/x.rb","view_range":[1,40]}
|
||||
{"command":"str_replace","path":"/workspace/x.rb","old_str":"a","new_str":"b"}
|
||||
{"command":"create","path":"/workspace/new.rb","file_text":"..."}
|
||||
{"command":"insert","path":"/workspace/x.rb","insert_line":10,"insert_text":"..."}
|
||||
stdout: the tool's result text (exit 0). stderr + exit 1: a tool error message.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from str_replace_editor_vendor.base import ToolError # noqa: E402
|
||||
from str_replace_editor_vendor.edit import EditTool # noqa: E402
|
||||
|
||||
# The keyword-only params the vendored EditTool.__call__ accepts.
|
||||
_ACCEPTED = {
|
||||
"command", "path", "file_text", "view_range",
|
||||
"old_str", "new_str", "insert_text", "insert_line",
|
||||
}
|
||||
|
||||
|
||||
async def _run(payload: dict):
|
||||
# Reject unknown keys instead of silently dropping them: a typo like
|
||||
# `old_string` (vs `old_str`) should be a clear argument error, not a
|
||||
# confusing failure deeper inside EditTool with the param silently missing.
|
||||
unknown = set(payload) - _ACCEPTED
|
||||
if unknown:
|
||||
raise ToolError(
|
||||
f"unknown argument(s): {', '.join(sorted(unknown))}. "
|
||||
f"accepted keys: {', '.join(sorted(_ACCEPTED))}."
|
||||
)
|
||||
kwargs = dict(payload)
|
||||
if "command" not in kwargs or "path" not in kwargs:
|
||||
raise ToolError("Both `command` and `path` are required.")
|
||||
# run_command_preexec_fn=None → no uid/gid demotion (see module docstring).
|
||||
tool = EditTool(run_command_preexec_fn=None)
|
||||
return await tool(**kwargs)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = sys.stdin.read()
|
||||
if not raw.strip():
|
||||
sys.stderr.write("str_replace_editor: expected a JSON object on stdin\n")
|
||||
return 2
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
sys.stderr.write(f"str_replace_editor: invalid JSON on stdin: {e}\n")
|
||||
return 2
|
||||
if not isinstance(payload, dict):
|
||||
sys.stderr.write("str_replace_editor: stdin JSON must be an object\n")
|
||||
return 2
|
||||
try:
|
||||
result = asyncio.run(_run(payload))
|
||||
except ToolError as e:
|
||||
sys.stderr.write((e.message or "tool error") + "\n")
|
||||
return 1
|
||||
except TypeError as e:
|
||||
# e.g. an unexpected/duplicate kwarg shape — surface like a tool error.
|
||||
sys.stderr.write(f"str_replace_editor: bad arguments: {e}\n")
|
||||
return 1
|
||||
# EditTool returns a (CLI)Result with .output / .error / .base64_image / .system
|
||||
if getattr(result, "error", None):
|
||||
sys.stderr.write(result.error if result.error.endswith("\n") else result.error + "\n")
|
||||
if getattr(result, "system", None):
|
||||
sys.stderr.write(f"[system] {result.system}\n")
|
||||
out = getattr(result, "output", None) or ""
|
||||
if getattr(result, "base64_image", None):
|
||||
out += "\n(image content omitted in CLI mode)"
|
||||
if out:
|
||||
sys.stdout.write(out if out.endswith("\n") else out + "\n")
|
||||
return 1 if getattr(result, "error", None) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1 +0,0 @@
|
||||
"""Vendored verbatim — do not edit. See VENDORED.md for provenance."""
|
||||
@@ -1,49 +0,0 @@
|
||||
from dataclasses import dataclass, fields, replace
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ToolResult:
|
||||
"""Represents the result of a tool execution."""
|
||||
|
||||
output: str | None = None
|
||||
error: str | None = None
|
||||
base64_image: str | None = None
|
||||
system: str | None = None
|
||||
|
||||
def __bool__(self):
|
||||
return any(getattr(self, field.name) for field in fields(self))
|
||||
|
||||
def __add__(self, other: "ToolResult"):
|
||||
def combine_fields(field: str | None, other_field: str | None, concatenate: bool = True):
|
||||
if field and other_field:
|
||||
if concatenate:
|
||||
return field + other_field
|
||||
raise ValueError("Cannot combine tool results")
|
||||
return field or other_field
|
||||
|
||||
return ToolResult(
|
||||
output=combine_fields(self.output, other.output),
|
||||
error=combine_fields(self.error, other.error),
|
||||
base64_image=combine_fields(self.base64_image, other.base64_image, False),
|
||||
system=combine_fields(self.system, other.system),
|
||||
)
|
||||
|
||||
def replace(self, **kwargs):
|
||||
"""Returns a new ToolResult with the given fields replaced."""
|
||||
return replace(self, **kwargs)
|
||||
|
||||
|
||||
# QUESTION(simon): What's our intent behind differentiating here?
|
||||
class CLIResult(ToolResult):
|
||||
"""A ToolResult that can be rendered as a CLI output."""
|
||||
|
||||
|
||||
class ToolFailure(ToolResult):
|
||||
"""A ToolResult that represents a failure."""
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""Raised when a tool encounters an error."""
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
@@ -1,476 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import shlex
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal, get_args
|
||||
|
||||
from .base import CLIResult, ToolError, ToolResult
|
||||
from .run import demote, maybe_truncate, run
|
||||
|
||||
TRUNCATED_MESSAGE: str = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
|
||||
|
||||
Command = Literal[
|
||||
"view",
|
||||
"create",
|
||||
"str_replace",
|
||||
"insert",
|
||||
]
|
||||
SNIPPET_LINES: int = 4
|
||||
|
||||
MAX_RESPONSE_LEN: int = 16000
|
||||
|
||||
|
||||
class EditTool:
|
||||
"""
|
||||
An filesystem editor tool that allows the agent to view, create, and edit files.
|
||||
The tool parameters are defined by Anthropic and are not editable.
|
||||
"""
|
||||
|
||||
def __init__(self, run_command_preexec_fn=demote):
|
||||
"""
|
||||
Initialize the EditTool.
|
||||
|
||||
Args:
|
||||
run_command_preexec_fn: Function to run in child process before executing
|
||||
shell commands via the run() utility.
|
||||
Defaults to demote() which drops privileges to uid/gid 1000.
|
||||
Pass None to skip preexec, or any callable for custom behavior.
|
||||
"""
|
||||
self._run_command_preexec_fn = run_command_preexec_fn
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
*,
|
||||
command: Command,
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_text: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
):
|
||||
_path = Path(path)
|
||||
self.validate_path(command, _path)
|
||||
if command == "view":
|
||||
return await self.view(_path, view_range)
|
||||
elif command == "create":
|
||||
if file_text is None:
|
||||
raise ToolError("Parameter `file_text` is required for command: create")
|
||||
await self.write_file(_path, file_text)
|
||||
return ToolResult(output=f"File created successfully at: {_path}")
|
||||
elif command == "str_replace":
|
||||
if old_str is None:
|
||||
raise ToolError("Parameter `old_str` is required for command: str_replace")
|
||||
return await self.str_replace(_path, old_str, new_str)
|
||||
elif command == "insert":
|
||||
if insert_line is None:
|
||||
raise ToolError("Parameter `insert_line` is required for command: insert")
|
||||
if insert_text is None:
|
||||
raise ToolError("Parameter `insert_text` is required for command: insert")
|
||||
return await self.insert(_path, insert_line, insert_text)
|
||||
raise ToolError(
|
||||
f"Unrecognized command {command}. The allowed commands for the {self.name} tool are: {', '.join(get_args(Command))}"
|
||||
)
|
||||
|
||||
def validate_path(self, command: str, path: Path):
|
||||
"""
|
||||
Check that the path/command combination is valid.
|
||||
"""
|
||||
# Check if its an absolute path
|
||||
if not path.is_absolute():
|
||||
suggested_path = Path("") / path
|
||||
raise ToolError(
|
||||
f"The path {path} is not an absolute path, it should start with `/`. Maybe you meant {suggested_path}?"
|
||||
)
|
||||
# Check if path exists
|
||||
if not path.exists() and command != "create":
|
||||
raise ToolError(f"The path {path} does not exist. Please provide a valid path.")
|
||||
if path.exists() and command == "create":
|
||||
raise ToolError(f"File already exists at: {path}. Cannot overwrite files using command `create`.")
|
||||
# Check if the path points to a directory
|
||||
if path.is_dir():
|
||||
if command != "view":
|
||||
raise ToolError(
|
||||
f"The path {path} is a directory and only the `view` command can be used on directories"
|
||||
)
|
||||
|
||||
async def view(self, path: Path, view_range: list[int] | None = None):
|
||||
"""Implement the view command"""
|
||||
if path.is_dir():
|
||||
if view_range:
|
||||
raise ToolError("The `view_range` parameter is not allowed when `path` points to a directory.")
|
||||
|
||||
_, stdout, stderr = await run(
|
||||
rf"find {path} -maxdepth 2 -not -path '*/\.*'", preexec_fn=self._run_command_preexec_fn
|
||||
)
|
||||
if not stderr:
|
||||
stdout = f"Here's the files and directories up to 2 levels deep in {path}, excluding hidden items:\n{stdout}\n"
|
||||
return CLIResult(output=stdout, error=stderr)
|
||||
|
||||
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.svg', '.ico'}
|
||||
if path.suffix.lower() in image_extensions:
|
||||
if view_range:
|
||||
raise ToolError("The `view_range` parameter is not allowed when `path` points to an image file.")
|
||||
|
||||
try:
|
||||
image_bytes = path.read_bytes()
|
||||
base64_encoded = base64.b64encode(image_bytes).decode()
|
||||
|
||||
return CLIResult(
|
||||
output=f"Displaying image file: {path}",
|
||||
base64_image=base64_encoded
|
||||
)
|
||||
except Exception as e:
|
||||
raise ToolError(f"Failed to read image file {path}: {e}") from None
|
||||
|
||||
file_content = await self.read_file(path, truncate_after=None)
|
||||
file_text_lines = file_content.splitlines(keepends=True)
|
||||
n_lines_file = len(file_text_lines) + (1 if file_content.endswith(("\n", "\r\n", "\r")) else 0)
|
||||
|
||||
if view_range:
|
||||
if len(view_range) != 2 or not all(isinstance(i, int) for i in view_range):
|
||||
raise ToolError("Invalid `view_range`. It should be a list of two integers.")
|
||||
init_line, final_line = view_range
|
||||
if init_line < 1 or init_line > n_lines_file:
|
||||
raise ToolError(
|
||||
f"Invalid `view_range`: {view_range}. Its first element `{init_line}` should be within the range of lines of the file: {[1, n_lines_file]}"
|
||||
)
|
||||
if final_line > n_lines_file:
|
||||
raise ToolError(
|
||||
f"Invalid `view_range`: {view_range}. Its second element `{final_line}` should be smaller than the number of lines in the file: `{n_lines_file}`"
|
||||
)
|
||||
if final_line != -1 and final_line < init_line:
|
||||
raise ToolError(
|
||||
f"Invalid `view_range`: {view_range}. Its second element `{final_line}` should be larger or equal than its first `{init_line}`"
|
||||
)
|
||||
|
||||
# Extract only the requested lines
|
||||
if final_line != -1:
|
||||
selected_lines = file_text_lines[max(view_range[0] - 1, 0) : view_range[1]]
|
||||
else:
|
||||
selected_lines = file_text_lines[max(view_range[0] - 1, 0) :]
|
||||
# Join without modifying the original line endings
|
||||
file_content = "".join(selected_lines)
|
||||
|
||||
file_content = process_view_output_str(
|
||||
file_text=file_content,
|
||||
path=str(path),
|
||||
total_path_lines=n_lines_file,
|
||||
max_resp_ln=MAX_RESPONSE_LEN,
|
||||
view_range=(view_range[0], view_range[1]) if view_range else None,
|
||||
)
|
||||
|
||||
return CLIResult(output=file_content)
|
||||
|
||||
async def str_replace(self, path: Path, old_str: str, new_str: str | None):
|
||||
"""Implement the str_replace command, which replaces old_str with new_str in the file content"""
|
||||
# Read the file content
|
||||
file_content = await self.read_file(path, truncate_after=None)
|
||||
new_str = new_str if new_str is not None else ""
|
||||
|
||||
# Check if old_str is unique in the file
|
||||
occurrences = file_content.count(old_str)
|
||||
if occurrences == 0:
|
||||
raise ToolError(f"No replacement was performed, old_str `{old_str}` did not appear verbatim in {path}.")
|
||||
elif occurrences > 1:
|
||||
file_content_lines = file_content.split("\n")
|
||||
lines = [idx + 1 for idx, line in enumerate(file_content_lines) if old_str in line]
|
||||
raise ToolError(
|
||||
f"No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines {lines}. Please ensure it is unique"
|
||||
)
|
||||
|
||||
# Replace old_str with new_str
|
||||
new_file_content = file_content.replace(old_str, new_str)
|
||||
|
||||
# Write the new content to the file
|
||||
await self.write_file(path, new_file_content)
|
||||
|
||||
# Create a snippet of the edited section
|
||||
replacement_line = file_content.split(old_str)[0].count("\n")
|
||||
start_line = max(0, replacement_line - SNIPPET_LINES)
|
||||
end_line = replacement_line + SNIPPET_LINES + new_str.count("\n")
|
||||
snippet = "\n".join(new_file_content.split("\n")[start_line : end_line + 1])
|
||||
|
||||
# Prepare the success message
|
||||
success_msg = f"The file {path} has been edited. "
|
||||
success_msg += self._make_output(snippet, f"a snippet of {path}", start_line + 1)
|
||||
success_msg += "Review the changes and make sure they are as expected. Edit the file again if necessary."
|
||||
|
||||
return CLIResult(output=success_msg)
|
||||
|
||||
async def insert(self, path: Path, insert_line: int, new_str: str):
|
||||
"""Implement the insert command, which inserts new_str at the specified line in the file content."""
|
||||
file_text = await self.read_file(path, truncate_after=None)
|
||||
file_text_lines = file_text.split("\n")
|
||||
n_lines_file = len(file_text_lines)
|
||||
|
||||
if insert_line < 0 or insert_line > n_lines_file:
|
||||
raise ToolError(
|
||||
f"Invalid `insert_line` parameter: {insert_line}. It should be within the range of lines of the file: {[0, n_lines_file]}"
|
||||
)
|
||||
|
||||
new_str_lines = new_str.split("\n")
|
||||
new_file_text_lines = file_text_lines[:insert_line] + new_str_lines + file_text_lines[insert_line:]
|
||||
snippet_lines = (
|
||||
file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
|
||||
+ new_str_lines
|
||||
+ file_text_lines[insert_line : insert_line + SNIPPET_LINES]
|
||||
)
|
||||
|
||||
new_file_text = "\n".join(new_file_text_lines)
|
||||
snippet = "\n".join(snippet_lines)
|
||||
|
||||
await self.write_file(path, new_file_text)
|
||||
|
||||
success_msg = f"The file {path} has been edited. "
|
||||
success_msg += self._make_output(
|
||||
snippet,
|
||||
"a snippet of the edited file",
|
||||
max(1, insert_line - SNIPPET_LINES + 1),
|
||||
)
|
||||
success_msg += "Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary."
|
||||
return CLIResult(output=success_msg)
|
||||
|
||||
async def read_file(self, path: Path, truncate_after: int | None = MAX_RESPONSE_LEN):
|
||||
"""Read the content of a file from a given path; raise a ToolError if an error occurs."""
|
||||
try:
|
||||
code, out, err = await run(
|
||||
f"cat {shlex.quote(str(path))}", truncate_after=truncate_after, preexec_fn=self._run_command_preexec_fn
|
||||
)
|
||||
if code != 0:
|
||||
raise ToolError(f"Ran into {err} while trying to read {path}")
|
||||
return out
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise ToolError(f"Ran into {e} while trying to read {path}") from None
|
||||
|
||||
async def write_file(self, path: Path, file: str):
|
||||
"""Write the content of a file to a given path; raise a ToolError if an error occurs."""
|
||||
try:
|
||||
# Write using stdin to avoid argument size limits
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"cat > {shlex.quote(str(path))}",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
preexec_fn=self._run_command_preexec_fn,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(input=file.encode('utf-8')),
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
raise ToolError(f"Ran into {stderr.decode()} while trying to write to {path}")
|
||||
except asyncio.TimeoutError:
|
||||
raise ToolError(f"Timed out while trying to write to {path}")
|
||||
except Exception as e:
|
||||
raise ToolError(f"Ran into {e} while trying to write to {path}") from None
|
||||
|
||||
def _make_output(
|
||||
self,
|
||||
file_content: str,
|
||||
file_descriptor: str,
|
||||
init_line: int = 1,
|
||||
expand_tabs: bool = True,
|
||||
):
|
||||
"""Generate output for the CLI based on the content of a file."""
|
||||
file_content = maybe_truncate(file_content)
|
||||
if expand_tabs:
|
||||
file_content = file_content.expandtabs()
|
||||
file_content = "\n".join([f"{i + init_line:6}\t{line}" for i, line in enumerate(file_content.split("\n"))])
|
||||
return f"Here's the result of running `cat -n` on {file_descriptor}:\n" + file_content + "\n"
|
||||
|
||||
|
||||
### AUX utilities
|
||||
|
||||
|
||||
def add_line_numbers(text: str, includes_final_line: bool, n_first_line: int = 1) -> str:
|
||||
"""
|
||||
Given a string, returns the string with line numbers prepended to each line.
|
||||
|
||||
This function:
|
||||
- Preserves the original line endings (CR, LF, or CRLF) of each line
|
||||
- Adds a tab-separated line number prefix to each line
|
||||
- If the text ends with any newline character (\n, \r\n, or \r), adds an
|
||||
additional empty numbered line to represent the terminal empty line
|
||||
"""
|
||||
lines_with_endings = text.splitlines(keepends=True)
|
||||
result = [f"{ind + n_first_line:6}\t{line_with_ending}" for ind, line_with_ending in enumerate(lines_with_endings)]
|
||||
|
||||
# Add an extra empty line with line number if original text ends with newline
|
||||
if includes_final_line and text.endswith(("\n", "\r\n", "\r")):
|
||||
result.append(f"{len(lines_with_endings) + n_first_line:6}\t")
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def process_view_output_str(
|
||||
file_text: str,
|
||||
path: str,
|
||||
total_path_lines: int,
|
||||
max_resp_ln: int,
|
||||
view_range: tuple[int, int] | None = None,
|
||||
) -> str:
|
||||
# Get header
|
||||
header = f"Here's the content of {path} with line numbers"
|
||||
if total_path_lines is not None and view_range is not None:
|
||||
header += f" (which has a total of {total_path_lines} lines) with view_range={list(view_range)}"
|
||||
|
||||
# See if final line is included in the view_range
|
||||
if view_range is None or view_range[1] == -1 or view_range[1] == total_path_lines:
|
||||
includes_final_line = True
|
||||
else:
|
||||
includes_final_line = False
|
||||
n_first_line = view_range[0] if view_range is not None else 1
|
||||
|
||||
# Truncate if needed
|
||||
maybe_truncated_str = truncate_from_middle_v2(ss=file_text, max_len=max_resp_ln, n_line_offset=n_first_line - 1)
|
||||
if isinstance(maybe_truncated_str, str):
|
||||
# No truncation
|
||||
file_text_with_line_numbers = add_line_numbers(
|
||||
file_text,
|
||||
includes_final_line=includes_final_line,
|
||||
n_first_line=n_first_line,
|
||||
)
|
||||
else:
|
||||
# Truncation occurred
|
||||
before_with_line_numbers = add_line_numbers(
|
||||
text="".join(maybe_truncated_str.as_str(maybe_truncated_str.before_lines)),
|
||||
includes_final_line=False,
|
||||
n_first_line=n_first_line,
|
||||
)
|
||||
if maybe_truncated_str.single_line:
|
||||
file_text_with_line_numbers = before_with_line_numbers
|
||||
else:
|
||||
after_with_line_numbers = add_line_numbers(
|
||||
text="".join(maybe_truncated_str.as_str(maybe_truncated_str.after_lines)),
|
||||
includes_final_line=includes_final_line,
|
||||
n_first_line=1 + maybe_truncated_str.truncated_end_line,
|
||||
)
|
||||
file_text_with_line_numbers = (
|
||||
before_with_line_numbers + f"\t{maybe_truncated_str.truncation_msg}" + after_with_line_numbers
|
||||
)
|
||||
|
||||
# Add context-aware truncation message
|
||||
if view_range is not None:
|
||||
# User already using view_range, suggest adjusting it
|
||||
truncation_note = "\n<response clipped><NOTE>To save on context only part of the view range has been shown. You can adjust the view_range parameters or use `grep -n` to find specific content.</NOTE>"
|
||||
else:
|
||||
# User viewing whole file, suggest view_range or grep
|
||||
truncation_note = "\n<response clipped><NOTE>To save on context only part of this file has been shown to you. You can use view_range=[start_line, end_line] to see specific sections, or use `grep -n` to find what you're looking for.</NOTE>"
|
||||
|
||||
file_text_with_line_numbers += truncation_note
|
||||
|
||||
return f"{header}:\n{file_text_with_line_numbers}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TruncatedString:
|
||||
# Blocks
|
||||
before_lines: list[str]
|
||||
middle_lines: list[str]
|
||||
after_lines: list[str]
|
||||
|
||||
# Line numbers (starting from 1)
|
||||
truncated_start_line: int
|
||||
truncated_end_line: int
|
||||
|
||||
# Truncation msg
|
||||
truncation_msg: str
|
||||
single_line: bool
|
||||
|
||||
def as_str(self, lines: list[str]) -> str:
|
||||
return "".join(lines)
|
||||
|
||||
@property
|
||||
def full_truncated_str(self) -> str:
|
||||
return "".join(self.before_lines + [self.truncation_msg] + self.after_lines)
|
||||
|
||||
|
||||
def truncate_from_middle_v2(ss: str, max_len: int, n_line_offset: int = 0) -> "str | TruncatedString":
|
||||
"""
|
||||
If no truncation is needed, returns the original string.
|
||||
If truncation is needed, returns TruncatedString
|
||||
"""
|
||||
# No truncation needed
|
||||
if len(ss) <= max_len:
|
||||
return ss
|
||||
|
||||
# Single line
|
||||
lines_with_endings = ss.splitlines(True)
|
||||
if len(lines_with_endings) == 1:
|
||||
chars_per_side = max(1, max_len // 2)
|
||||
truncated_char_count = len(ss) - (chars_per_side * 2)
|
||||
truncation_msg = f"...< truncated {truncated_char_count} characters >..."
|
||||
|
||||
before_lines = [ss[:chars_per_side] + truncation_msg + ss[-chars_per_side:]]
|
||||
|
||||
return TruncatedString(
|
||||
before_lines=before_lines,
|
||||
middle_lines=[],
|
||||
after_lines=[],
|
||||
truncated_start_line=1 + n_line_offset,
|
||||
truncated_end_line=1 + n_line_offset,
|
||||
truncation_msg=truncation_msg,
|
||||
single_line=True,
|
||||
)
|
||||
|
||||
# Line truncation
|
||||
current_len = 0
|
||||
before_lines = []
|
||||
middle_lines = deque(lines_with_endings)
|
||||
after_lines = deque([])
|
||||
while current_len < max_len and len(middle_lines) > 1:
|
||||
# Before
|
||||
before_candidate_line = middle_lines[0]
|
||||
if len(before_candidate_line) + current_len <= max_len:
|
||||
before_lines.append(middle_lines.popleft())
|
||||
current_len += len(before_candidate_line)
|
||||
else:
|
||||
break
|
||||
|
||||
# After
|
||||
if len(middle_lines) > 1:
|
||||
after_candidate_line = middle_lines[-1]
|
||||
if len(after_candidate_line) + current_len <= max_len:
|
||||
after_lines.appendleft(middle_lines.pop())
|
||||
current_len += len(after_candidate_line)
|
||||
else:
|
||||
break
|
||||
|
||||
# Find truncated lines
|
||||
first_truncated_line = 1 + len(before_lines) + n_line_offset
|
||||
last_truncated_line = first_truncated_line + len(middle_lines) - 1
|
||||
if ss.endswith(("\n", "\r", "\r\n")) and len(after_lines) == 0:
|
||||
last_truncated_line += 1
|
||||
|
||||
# Create truncation msg
|
||||
if first_truncated_line == last_truncated_line:
|
||||
truncation_msg = f"< truncated line {first_truncated_line} >"
|
||||
else:
|
||||
truncation_msg = f"< truncated lines {first_truncated_line}-{last_truncated_line} >"
|
||||
if len(after_lines) != 0:
|
||||
if before_lines[0].endswith("\r\n"):
|
||||
truncation_msg += "\r\n"
|
||||
elif before_lines[0].endswith("\r"):
|
||||
truncation_msg += "\r"
|
||||
else:
|
||||
truncation_msg += "\n"
|
||||
|
||||
return TruncatedString(
|
||||
# Blocks
|
||||
before_lines=before_lines,
|
||||
middle_lines=list(middle_lines),
|
||||
after_lines=list(after_lines),
|
||||
# Line numbers (starting from 1)
|
||||
truncated_start_line=first_truncated_line,
|
||||
truncated_end_line=last_truncated_line,
|
||||
# Truncation msg
|
||||
truncation_msg=truncation_msg,
|
||||
single_line=False,
|
||||
)
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Utility to run shell commands asynchronously with a timeout."""
|
||||
|
||||
import asyncio # noqa -- swapping to trio would be beneficial, but not blocking atm
|
||||
import os
|
||||
|
||||
TRUNCATED_MESSAGE: str = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
|
||||
MAX_RESPONSE_LEN: int = 16000
|
||||
|
||||
|
||||
def maybe_truncate(content: str, truncate_after: int | None = MAX_RESPONSE_LEN):
|
||||
"""Truncate content and append a notice if content exceeds the specified length."""
|
||||
return (
|
||||
content
|
||||
if not truncate_after or len(content) <= truncate_after
|
||||
else content[:truncate_after] + TRUNCATED_MESSAGE
|
||||
)
|
||||
|
||||
|
||||
def demote():
|
||||
"""Drop privileges to uid/gid 1000 for security.
|
||||
|
||||
This function is intended to be used as a preexec_fn in subprocess calls
|
||||
to ensure commands run with reduced privileges.
|
||||
"""
|
||||
os.setgid(1000)
|
||||
os.setuid(1000)
|
||||
|
||||
|
||||
async def run(
|
||||
cmd: str,
|
||||
timeout: float | None = 120.0, # seconds # noqa: ASYNC109
|
||||
truncate_after: int | None = MAX_RESPONSE_LEN,
|
||||
preexec_fn=demote,
|
||||
):
|
||||
"""Run a shell command asynchronously with a timeout.
|
||||
|
||||
Args:
|
||||
cmd: Command to execute
|
||||
timeout: Command timeout in seconds
|
||||
truncate_after: Maximum response length before truncation
|
||||
preexec_fn: Function to run in child process before exec (default: demote).
|
||||
Pass None to skip preexec, or any callable for custom behavior.
|
||||
|
||||
Returns:
|
||||
Tuple of (return_code, stdout, stderr)
|
||||
"""
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
preexec_fn=preexec_fn,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
return (
|
||||
process.returncode or 0,
|
||||
maybe_truncate(stdout.decode(), truncate_after=truncate_after),
|
||||
maybe_truncate(stderr.decode(), truncate_after=truncate_after),
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
try:
|
||||
process.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise TimeoutError(f"Command '{cmd}' timed out after {timeout} seconds") from exc
|
||||
@@ -1,20 +0,0 @@
|
||||
# Your actual toolset (this overrides any earlier tool guidance above)
|
||||
|
||||
This harness gives you exactly two ways to act, both through the `Bash` tool:
|
||||
|
||||
1. **Shell commands** for everything read-only and for running things: view and search files with `cat`, `sed -n`, `grep -rn`, `find`, `ls`; run tests; run `git`; etc.
|
||||
2. **A `str_replace_editor` file editor**, which you invoke from Bash by piping ONE JSON object on stdin to `/opt/agent-cli/str_replace_editor`. Use a quoted heredoc so backslashes and quotes survive:
|
||||
|
||||
`/opt/agent-cli/str_replace_editor <<'EDITOR'` then a line of JSON then `EDITOR`
|
||||
|
||||
The JSON `"command"` field selects the operation:
|
||||
- `view` — view a file (optionally `"view_range":[start,end]`) or list a directory: `{"command":"view","path":"/abs/file.rb"}`
|
||||
- `create` — create a NEW file (fails if it exists): `{"command":"create","path":"/abs/new.rb","file_text":"..."}`
|
||||
- `str_replace` — replace a UNIQUE substring: `{"command":"str_replace","path":"/abs/file.rb","old_str":"...","new_str":"..."}`
|
||||
- `insert` — insert text after a line: `{"command":"insert","path":"/abs/file.rb","insert_line":N,"insert_text":"..."}`
|
||||
|
||||
Paths must be absolute. Inside JSON strings, escape newlines as `\n` and double-quotes as `\"`.
|
||||
|
||||
There are **no** `Read`, `Grep`, `Glob`, `Edit`, `Write`, `MultiEdit`, `NotebookEdit`, `Task`, `TodoWrite`, or `AskUserQuestion` tools — `Bash` is your only built-in tool. So disregard the earlier "Prefer the dedicated file/search tools over shell commands" guidance and the Memory section's "use the Write tool" instruction: those tools are not available in this harness. Search and read with shell commands; view, create, and edit files with `str_replace_editor`.
|
||||
|
||||
There is also no tool for asking the user an interactive question. If you need to ask the user something, or raise a concern about the request before acting on it, put it in your normal text response.
|
||||
@@ -1,4 +0,0 @@
|
||||
## Browser
|
||||
|
||||
Chromium is available in this environment via Playwright. `pw <script.js>` runs Node with
|
||||
`require("playwright")` resolvable (CommonJS — `import` will not find it).
|
||||
@@ -1,7 +0,0 @@
|
||||
## Correction to the toolset above: you also have `Read`
|
||||
|
||||
This task runs with `Read` in addition to `Bash`, so the statement above that there is no `Read`
|
||||
tool does not apply here. `Read` renders images — use it to look at a screenshot you have
|
||||
written to disk. Everything else above still holds: no `Grep`, `Glob`, `Edit`, `Write`,
|
||||
`MultiEdit`, `NotebookEdit`, `Task`, `TodoWrite` or `AskUserQuestion`, and you still create and
|
||||
edit files with `str_replace_editor`.
|
||||
Reference in New Issue
Block a user