Skip to content

feat: tool_discovery solver — agent learns the API through tools - #412

Open
JackHopkins wants to merge 16 commits into
mainfrom
feat/tool-discovery-solver
Open

feat: tool_discovery solver — agent learns the API through tools#412
JackHopkins wants to merge 16 commits into
mainfrom
feat/tool-discovery-solver

Conversation

@JackHopkins

Copy link
Copy Markdown
Owner

What

A new Inspect solver variant, tool_discovery, that inverts the standard prompt design: instead of injecting the full SystemPromptGenerator manual into the system prompt, the agent gets a minimal prompt plus Inspect-native tools to discover the rules itself:

  • list_methods() — enumerate the environment API
  • manual(method) — full documentation for one method (same per-tool agent.md files the MCP server serves)
  • observe() — current inventory/position/entities (free)
  • run_code(code) — execute Python in the environment (counts against the step budget)

Only run_code consumes trajectory_length; manuals and observation are free, so scores stay comparable with controlled — the cost of acquiring the rules shows up as tokens/latency, not lost game actions. A 3×-budget generation cap halts models that read forever without acting.

Server allocation (SimpleServerPool), TrajectoryData scoring, and pass@k wiring are unchanged. Registered in SOLVER_MAP, the --solver CLI choices, and gains an open_play_tool_discovery task via the existing variant machinery. The solver-choices sync test passes.

Why

None of the 12 existing solver variants ablate the manual — they ablate observations/images/HUD. The MCP server implements rules-discovery interactively but has no eval harness; PR #158 tried a RAG variant years ago (unmergeable). This makes "can the agent learn the environment?" a measurable question.

Smoke test (live server, OpenRouter, gpt-4o-mini, 8-step budget)

iron_ore_throughput: score 21.0/16 → scorer 1.0. Trajectory shows the intended behavior: list_methods ×1, manual ×7, observe ×1, run_code ×8 (exact budget), clean server release. 2m06s, 58k tokens.

Known gaps (follow-ups)

  • Per-step score/timing arrays in TrajectoryData are not populated (only totals/finals)
  • automated_production_score is not computed (needs the flows-delta logic from the controlled solver); the smoke run hand-mined its ore
  • No vision/render tool yet — text-only, closest comparison is text_only/controlled

SimpleServerPool assumed every run_idx in range had a container behind
it. With fewer real servers than epochs, most rollouts crashed inside
make_factorio_env and Inspect recorded them as errored samples while the
eval completed — e.g. pass_at_8 scored over 1 real rollout out of 8.

- Probe server reachability at pool init and only allocate real servers;
  raise if none are reachable. Rollouts now wait for a free server (30min
  timeout), so 8 epochs on 1 container run sequentially instead of erroring.
- fle inspect-eval passes --fail-on-error by default so any errored sample
  fails the eval; --no-fail-on-error restores the old behavior.

Verified: production_science_pack_throughput with epochs=8 on a single
container now completes 8/8 samples (was 1/8).
Every existing solver injects the full SystemPromptGenerator manual into
the system prompt and ablates observations/images. tool_discovery inverts
the ablation: a minimal system prompt plus Inspect-native tools
(list_methods, manual, observe, run_code) sourced from the same per-tool
agent.md files the MCP server serves, so acquiring the rules is part of
the measured behavior.

Only run_code counts against the trajectory_length budget; manuals and
observation are free, keeping scores comparable with controlled. Server
allocation, TrajectoryData scoring, and pass@k wiring are unchanged.
Registered in SOLVER_MAP and the --solver CLI choices (sync test passes).
run_code returned only the formatted observation; the agent could not
see what its program printed or why it failed. Surface info['result']
(the same source the controlled solver uses) ahead of the game state.
Three epochs of a production run hung indefinitely inside generate()
against a stalled provider connection. 300s timeout with 3 retries turns
a hung request into a bounded delay.
Trimming could cut between an assistant tool_call and its tool result;
OpenAI rejects such histories (No tool call found for call_id). Drop
leading orphaned tool results from the kept tail.
A single transient get_entities failure (engine busy, RCON hiccup) killed
an entire multi-hour eval: one rollout errored ~1h in and fail-on-error
cancelled the other three healthy epochs. The action sequence replayed
clean, confirming the failure was transient. Retry up to 3 times with
short backoff, and include the underlying error in the raised message —
the previous wrapper discarded it, making the failure undiagnosable from
the eval log.
run_code streamed the full formatted game state back after every step —
the controlled-solver habit leaking into the tool harness. Now run_code
returns only the program's STDOUT/STDERR plus a step counter, and the
agent pulls state deliberately through dedicated free tools:

- task(): objective, quota, current score, remaining budget
- entities(): entities on the map (compact reprs)
- inventory(): carried items
- summary(): one-glance position/score/budget/counts overview

observe() is superseded. Documentation tools unchanged.
The RCON client rejects concurrent calls (ClientBusy: 'The client is
already busy with another call'), and Inspect may execute multiple tool
calls from a single assistant message in parallel — entities()/
inventory() racing a run_code() step corrupted responses and killed
epochs. All env-touching tools now share an asyncio.Lock.
score() after a step could fail transiently (e.g. KeyError 'player'
from a corrupted RCON response) and kill the epoch. Apply the same
retry-with-backoff used for get_entities, via a shared _call_with_retry
helper used by both sites.
The per-call sliding window (head + last-40) shifted the prompt prefix
on every generation once history exceeded the window, defeating provider
prompt caching: measured trajectories cache-hit only the ~784-token
system head, re-billing ~25-40k input tokens on every call. Trim only
when crossing a high-water mark (120), cutting to a low-water mark (60),
so the prefix stays stable for ~60 generations between trims. Trimming
mutates the shared list so the stable prefix persists across calls.
trim_messages re-trimmed every 1-2 steps with the configured windows
(e.g. 12/8, 25/16), shifting the prompt prefix on nearly every
generation and defeating provider prompt caching, same pathology as the
tool_discovery sliding window. Trigger trimming only above
max(max_messages, 2*trim_to + 1); the kept context after a trim is
unchanged so each variant's context ablation semantics are preserved —
the context ceiling between trims grows slightly in exchange for a
prefix that stays cache-stable for many steps.
Unpinned OpenRouter routing bounces requests across providers, splitting
or missing their prompt caches. Resolve the model's top tool-capable
provider from the endpoints API at CLI startup and pin routing to it
(order + allow_fallbacks=false). --pin-provider overrides the choice;
--no-pin-provider disables pinning. Best-effort: any resolution failure
falls back to unpinned routing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant