Repository navigation
feat(shapes): built-in ACP, model-direct and command-template solutions - #30
Merged
Merged
Conversation
…ilt-in shapes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e's program Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t agent Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l broken-pipe path Removes the "stdin is None" branch in close() by asserting once in __init__ and storing self._stdin/self._stdout, instead of re-checking Popen.stdin's optionality in three places for a case Popen(stdin=PIPE) never produces. Replaces the object.__new__ + fake-unclosable-stdin stand-in for close()'s OSError branch with a real one: a crashed agent's stdin read end goes away, so a notify() afterwards hits a genuine broken pipe in _send, and close() then re-attempts the same failing flush and must swallow the OSError from its own stdin.close() too. The previous test's comment claiming that branch was unreachable was wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…once, cancel at deadline Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The malformed-update test proved the mapping but didn't say so: content.text: null makes MessageCollector.on_update raise, AcpConnection's reader thread (Task 3) turns that into an AcpError, and _converse maps it to AGENT_ERROR like any other agent-side failure. Make that chain explicit in the test docstring and the fake agent's comment so it isn't mistaken for dedicated malformed-input handling in the collector. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ends an empty answer Two bugs from review round 1: - _open_session and apply_config took `timeout` once and handed the full amount to every conn.call they made, so initialize could answer late and session/new (or the Nth set_config_option) still got a fresh full timeout — the deadline meant to bound the whole handshake/config step could be blown many times over, outliving the runner's own timeout and orphaning the agent. Both now treat timeout as a total budget: compute an end time once and pass what's left of it to each call. - MessageCollector, for an agent that sends no messageId, only opened a new message lazily on the next chunk after a tool call. A turn that ended right on a tool call (no chunk after it) kept answering with the pre-tool-call text instead of "". Tool calls now close the running no-id message immediately via a shared _start_new_segment helper, which also absorbs a tool call already followed by another (or by nothing) without leaving a stray empty message. Also reworded test_shapes_acp.py's section headers and comments that cited "Ruling N" / "Task 3" / "Task 4" — process documents future readers won't have — to describe the behaviour under test instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ence The ACP bridge's skill-install test only checked that ".claude" appeared in a top-level directory listing, and the hints-level test only checked .is_file() — neither would catch install_skill landing the skill at the wrong depth or with truncated content. The fake agent now logs a full text dump of its cwd, keyed by relative path, so both tests can assert on exact file content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etered by the proxy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… reply - --system-file is read as UTF-8 and a decode failure is a config error like any other unreadable file, not a bare UnicodeDecodeError traceback. - parse_reply always returns a plain string for an OpenAI-compatible reply's text, even when a reasoning model (Mistral's magistral- included) sends content as a list of thinking/text parts instead of a string. - a reply shaped nothing like the API (parse_reply raising AttributeError, TypeError or IndexError) is reported as an agent error instead of crashing. - an OpenAI-style refusal field is read as a refusal (exit 20), not as a reply with no visible text (exit 23). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t code - run_group decodes a program's output with bad bytes replaced, so a stray non-UTF-8 byte no longer crashes tp shape cmd and loses the answer (the runner reads the shape's stdout as text, so relaying raw bytes would only move the crash into it). - A program exec refuses (no execute bit, bad shebang) is a config error, 24. - A program killed by signal N exits 128 + N, as a shell reports it; -9 used to reach the runner as 247. - Inputs that cannot be copied (a dangling symlink, an unreadable file) are a config error and the half-filled work directory is removed. - The question is read as UTF-8 whatever the locale; one that is not valid UTF-8, or cannot be read, is a config error instead of a traceback. - .DS_Store, Thumbs.db and desktop.ini are not input files, so a task folder opened once in Finder no longer makes tp shape direct refuse every case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ceback
Pending.wait now hands back only protocol-shaped values: a result that is not
an object reads as {}, and an error member of any shape (a bare string, a
non-integer or boolean code) becomes an ordinary AcpError. configOptions keeps
only its object entries, in session/new's reply and in a set_config_option
echo; a session/new configOptions that is present but not a list is a
protocol error (exit 23, "could not open a session"), not an agent that
offers no model. option_values tolerates a non-list options member, and
reported_no_model_use checks _meta and quota are objects and falls through to
the quota when usage carries no token counts (usage: {}).
--describe maps the same malformed replies to exit 23, and the reader thread
skips a stdout line nested too deep for the JSON parser instead of dying with
a RecursionError.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nto ShapeExit codes - An API key an HTTP header cannot carry (non-ASCII, or a control character such as a trailing newline) is a config error, 24, said without the key: httpx raised UnicodeEncodeError and h11 quotes a bad header value. - A base URL httpx cannot use (InvalidURL, which is no HTTPError, or a missing/unsupported scheme) is a config error, 24. - A reply whose choices is an object is "not in the expected format" (23): parsing catches LookupError, not only IndexError. - The stop reason is read as text or None, so an unhashable one is an unexpected stop reason (23) instead of a TypeError in exit_for. - tp's typer app never prints a traceback's locals, whatever typer's default: they can hold request headers, and the key with them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One test per shape feeds garbage at each boundary it has — its arguments, the case's manifest, question and inputs, and the program, agent or vendor on the other side — and asserts only that the case ends in the exit table with a [trap] line on failure and no traceback (an uncaught exception in the ACP reader thread fails the test too). Branch coverage cannot hold this line: a line that raises still counts as covered. Against the shapes as they were before this fix wave, 21 of the 87 cases fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…shape exits cmd and acp start their child in its own session, so the terminal's Ctrl-C never reaches it, and under tp run the runner SIGKILLs the shape 0.25 s after a Ctrl-C — so a Ctrl-C (or a TERM) used to leave the program, the agent and the agent's own children running, and the work directory behind. While a child's group runs (run_group for cmd; the agent's whole lifetime in run_case and describe_agent for acp), the main thread now handles SIGINT and SIGTERM itself: the handler SIGKILLs the group at once, then raises SystemExit(128 + signal), so every finally on the way out still runs (the work directory is removed) and the shape exits 130 or 143 with no traceback, through python -m and tp shape alike. The previous handlers are always put back. Off the main thread nothing is installed. run_group also kills the group on any other exception unwinding through it, and kill_group / kill_now treat EPERM like "gone": macOS answers it for a group left with only an unreaped child, which is exactly the state an interrupt leaves behind. Tests signal the shape from outside (python -m trap.shapes.command and python -m trap.shapes.acp against the hang_hard agent) and in-process, and every shape test module now fails a test that leaves the SIGINT or SIGTERM handler altered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to stderr Only the last message is the answer, so when the pick is wrong the case's stderr must show what came before it. The message collector now notes, as they happen and in order with the permission notes: each earlier agent message (cut to 500 characters) once a tool call or another message follows it, and each tool call's title and status when it starts and whenever an update changes its status. stdout is unchanged — still only the answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…b stop, exit codes as shipped - tp shape acp needs an agent that offers a model option; it is verified with claude-acp and codex-acp only, and the guide no longer lists Gemini CLI or Cursor as agents it drives (the module and command docstrings neither). - Claude Code's settings isolation applies only with --agent-id claude-acp; without it the runner's own CLAUDE.md, hooks and plugins load. - The environment scrub prevents accidental discovery, not a determined agent: a same-user process can still read the shape's own environment. - --describe runs with "a scrubbed environment, scrubbed the same way a case's is"; the cli reference synopsis shows --model as optional, required unless --describe. - The exit-code paragraph for tp shape cmd matches what it now does (exec failures and bad inputs are 24, a signal-killed program is 128 + N, an interrupt 128 + the signal), and the guide covers the new behaviour: the question read as UTF-8, uncopyable inputs, interrupts, the stderr process log, malformed ACP replies, OS junk files and bad keys or base URLs for direct, and undecodable program output for cmd. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integer check moved into the conditional expression that uses it, so pyright knows the code handed to AcpError is an int. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…per exported Both tests read CODEX_CONFIG back from the agent's environment, and the case's env scrub has no reason to drop one the developer's shell exported, so with CODEX_CONFIG set the ChatGPT-login test failed. They now unset it first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… print A reply carrying a lone UTF-16 surrogate (json.loads of a JSON escape like "\ud800") has no UTF-8 encoding: print(reply.text) and print(outcome.answer) raised UnicodeEncodeError straight through, an uncaught traceback and exit 1 where every path out of a shape's main() must end in a ShapeExit code. print_answer in _case.py, used by direct and the acp bridge, tries the plain print first and only on UnicodeEncodeError re-encodes with the bad character replaced — the answer still prints, and the exit code still reflects the reply's own stop reason. cmd already relays child output decoded with errors="replace", so it needed no change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mples both verified The "answers that are not answers" paragraph overstated its case: a non-object initialize or session/set_config_option result is read as empty and the case carries on regardless, not a 23. Only a non-object session/new result (no session id) or session/prompt result (no stopReason) surfaces downstream as a failure — 23 when running a case, or, for session/new under --describe, an empty options list and exit 0. A JSON-RPC error of any shape and a non-list configOptions still reliably exit 23, as before. The --agent-id row also listed gemini as an example id alongside claude-acp and codex-acp, though the guide already says only those two are verified; dropped it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n reach the answers CaseSandbox.open copied a case's inputs with shutil.copytree's default dereferencing behaviour, so a symlink under inputs/ pointing at expected/ landed in the work directory as a regular file holding the real answer. Copy with symlinks=True so a link is never dereferenced, then decide by walking the work directory afterwards: any symlink found there — a file, a directory, dangling, or pointing only inside the case's own inputs — refuses the case (ShapeExit.CONFIG_ERROR) before any program or agent starts, naming the links (capped) without ever printing what they point at. inputs_dir itself being a symlink (only reachable via a hand-made manifest) is refused the same way, checked before a work directory even exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t-file no longer leak One copy helper (copy_tree_without_symlinks) now backs both the case's inputs and --skill: it never creates a symlink in the destination, verifies the result with a fail-closed walk (a directory the walk cannot read is a refusal, not a silent pass), and gives the owner rwx on every directory it copies. The work directory itself is chmod'd 0700 regardless of the inputs' mode, and removal (remove_tree, used by close() and every refusal path) reclaims a missing write bit and retries instead of leaving a trap-case-* directory behind. install_skill converts every failure to a ShapeError so bridge.py never mislabels a bad --skill as "cannot start the agent". --prompt-file can no longer be absolute or contain "..", which used to let the existence check and the later read disagree about which file they meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… shape can't tell Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it, never costs the answer A shape prints its answer after removing the case's work dir, so a cleanup that raised threw the answer away and exited 1 with a traceback: rmtree's error handler retried whatever call had failed, which for os.open (a 000 directory) is a TypeError, and for a 444 directory or a read-only one holding a dangling link ended in ENOTEMPTY. The same handler chmod'd the failing path's parent (TMPDIR, at the top) and the path itself through os.stat — so a link in a read-only directory had its target outside the work dir chmod'd. remove_tree now first gives the owner rwx on the root and every real directory under it, top-down and lstat-checked, each before it is listed — never a symlink, never anything above the root — then runs rmtree with every failure recorded and skipped, never retried. It never raises: what is left is named in one "[trap] could not remove the work dir <path>: <reason>" line on stderr. A refused case or skill is still exit 24 however its cleanup went. The work dir is also made 0700 as soon as copytree returns, before the copy is walked, rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r is named on stderr Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… work dir is always reported
remove_tree used os.path.lexists(root) to decide whether the work dir was
gone, but lexists returns False on any lstat error, not only "gone" — a
program that left the work dir's own parent unsearchable (its own chmod
000 ..) made the check silently agree the dir was removed. Only
FileNotFoundError from os.lstat(root) now means gone; every other lstat
error is reported.
The recording onexc callback for shutil.rmtree kept only the exception,
whose own message names just the bare entry a descriptor-based unlink
failed on ("f" for a failure on "d/f"). It now records (path, exception)
pairs, and the warning names the actual failing path.
run_group returned as soon as the program's own process exited, without
touching its process group — a program that backgrounded something (a
stray `sleep &`, output sent to /dev/null) left it running past the
shape's own exit, free to race the work dir removal that follows. The
whole group is now SIGKILLed before every return, deadline and interrupt
included them already; only the normal-exit path was missing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A solution is handed its case's inputs as a resolved path, so a case directory that is, holds, or sits inside expected answers -- through a symlink (inputs/c1 -> ../expected/c1, both linked to one shared folder, inputs -> expected) or through dirs naming one directory for both -- hands every solution the answers, and no solution or built-in shape can tell it from an ordinary directory. answer_overlaps() compares each case's resolved inputs with its resolved answers and the resolved expected root, by path component. TaskRunner .refuse_answer_overlap() raises ConfigError naming the task and each offending case with both paths; run() calls it before the first case, and tp run calls it before a live session or site-graded run is opened, so a refused task exits 2 with nothing started. A case linked to a folder without the answers, and a missing expected dir, still run normally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s such a task writing-task.md and the dirs field state the rule, symlinks included. built-in-shapes.md no longer leaves a case directory linked to the answers to the task author: trap refuses that task before any case runs. A hard link or a copy of an answer inside the inputs still is the author's to avoid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pe the message The overlap check compared resolved path strings only, so on a filesystem that ignores letter case or Unicode form (default macOS), or through a firmlink, one directory spelled two ways passed: inputs/c1 -> ../Expected/c1, dirs Data/ against data/, an NFD link against an NFC dir, and a /System/Volumes/Data alias all handed the solution its answers. Directories now also match by (st_dev, st_ino), for equality and for containment by walking the child's parents; a stat that fails (missing, a link loop, no search permission) matches nothing, and the path rule still applies. Sitting inside the expected root now counts only outside the inputs root, so dirs.expected: ./ runs, while inputs/c1 -> ../expected/c2 is still refused. Each refused case says how its inputs overlap: are, contain or sit inside its own answers or the expected root. tp run refuses in the loader block, before the unanchored-provenance prompt, any live session or site-graded run, or any case; TaskRunner.run() still refuses for every caller. The message is escaped before Rich prints it, so a case id such as "c[/x]" is shown as written instead of crashing the CLI. ConfigError moves to the leaf module trap.errors, re-exported from trap.loader.errors; the runner imports it from there, so it no longer depends on the loader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he author It follows links on each case directory's own path and on the inputs and expected dirs, and compares the real directories, so a different letter case or Unicode spelling on macOS counts. It does not look at links inside a case directory (the built-in shapes refuse those), and a hard link or a copy of an answer is still the author's to avoid. traptask-yaml.md states the exact rule, including that an expected dir around inputs (./) is fine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot is around the inputs
The exemption for a case dir that sits inside the expected root but under
the inputs root was meant for an expected root around the inputs
(dirs.expected: ./). Where the expected root is inside the inputs root,
every case dir inside it is under the inputs root too, so the exemption
swallowed every such case: dirs {inputs: cases/, expected: cases/_answers/}
with cases/c1 -> _answers/c2, and {inputs: ./, expected: expected/} with
c1 -> expected/c2, both ran and handed c1 the answers of c2.
The exemption now also requires the expected root not to be inside (or be)
the inputs root. It only narrows the exemption, so it refuses nothing new
beyond those layouts: dirs.expected: ./ and a clean dirs.inputs: ./ task
still run. The traptask.yaml reference states the corrected rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot just its own
The overlap check compared a case's inputs with its own answers and with
the expected root, so a case whose inputs are another case's answers ran:
with dirs {inputs/, ./}, case ids c2 and inputs/c2 put inputs/c2's answers
at c2's inputs (and inputs/c2/key's inside them); the same with
{data/in/, data/} and in/c2; an inputs root linked into c2's answers put
c1's inputs inside them while only c1 was selected; and, with default dirs,
expected/c2 -> ../inputs/c1 made c1's inputs exactly c2's answers. Each
handed a solution another case's answers.
Now the inputs of every case about to run are checked against the answers
of every case the task defines, selected or not, skipped included: the same
directory or nested either way, by path and by identity on disk. Both sides
are indexed by path and identity and each directory's parents are looked up
in the other side's index, so the check stays linear in cases times path
depth, stat'ing each directory once; a directory that can't be stat'ed
matches by path alone. The refusal names the other case whose answers the
inputs would hold; the root clauses are unchanged and come last.
The docs state exactly what is refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lution is handed, no answers inside it Replace the overlap machinery in runner/task.py with runner/leaks.py. Before any case runs, trap refuses a task when: - a case id is empty, absolute or has a ".." component; - a running case's inputs hold a symlink anywhere from below the inputs root down to and inside the case directory (the inputs root itself may be a link), or a directory there cannot be read; - the expected root or any defined case's answers directory is, or sits inside, a directory of a running case's inputs; - any defined case's answers directory is a running case's directory or one of its ancestors. Directories are compared by (device, inode). A case directory linked to a clean folder, a link inside a case, an unreadable directory inside a case and an id with a ".." that stays inside are now refused; the built-in shapes keep their own link check for hand-made manifests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or around it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d mid-walk The leak check walked and stat'ed traptask_dir / dirs.* as written, while the runner hands the solution and the judge those roots resolved. Resolving takes a ".." after a missing or unsearchable folder by its text, where the OS fails on it, so with dirs.inputs "gone/../inputs/", an inputs root linked to "gone/../stored", or dirs.expected "gone/../inputs/c1/ans/", the check saw no inputs or no answers while the solution was handed a real folder nobody walked. Both now resolve every root through one helper, layout.task_root. A file or folder vanishing during the walk now refuses the task instead of leaving the rest of that case unchecked; only a case folder that isn't there counts as no inputs. Docs: no case's answers directory may be or lie around a case's directory (the expected root may), and an answer placed in the inputs isn't checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three built-in solutions ("shapes") that a hand-written
trap.yamlcan use as itscmd:, so an agent product, a bare model, or someone else's program can be tested without a wrapper repo and without glue code:cmd:tp shape acpclaude-acp,codex-acp)tp shape acp --agent-cmd "npx -y @agentclientprotocol/claude-agent-acp@0.76.0" --agent-id claude-acp --model sonnettp shape directtp shape direct --model claude-sonnet-5tp shape cmdtp shape cmd --repo . --template "python {repo}/main.py --question {prompt}"Each shape is an ordinary solution under the existing IO contract: the runner starts it as the solution subprocess, it reads
TRAP_MANIFEST, prints the answer on stdout and exits with a fixed code. The runner, judge, grader,report.jsonand live sync are untouched, and the existing cost proxy meters the shapes like any other solution.tp shapeis a hidden sub-app.This is phase 1 of the solution-registry work: the building blocks only. The zero-config front end (
tp run --agent claude-acp --model sonnet, a card recorded in the run's provenance, first-case confirmation) comes next, built on these interfaces.What every shape guarantees
Fresh work directory per case:
mkdtemp(prefix="trap-case-"), mode 0700, holding a copy of that case's inputs only. It is never under the task checkout or.trap/, and it is removed afterwards.No links into the answers: a case whose inputs contain a symlink anywhere is refused with
24, and so is anacp --skilldirectory that contains one. A link could point atexpected/, so the copy never creates or follows one. What trap cannot tell apart from ordinary files is documented as the task author's responsibility:The runner refuses a task that would hand a solution its answers (new, applies to every solution).
tp runexits 2 before any case runs, and before any prompt or site traffic, if any of these holds:.or..segment.Answers directories are resolved through links and compared by
(st_dev, st_ino), so macOS case, Unicode and firmlink spellings count. The built-in shapes check again for hand-made manifests. The manifest and the IO contract are unchanged.No answer leaks through the environment: the child never gets
TRAP_MANIFEST, the task's manifest variable, or any variable naming the inputs root or a--scrubpath.PATH HOME TMPDIR LANG SHELL TERM USER LOGNAMEare never dropped. (This stops accidental discovery, not a determined same-user agent; the guide says so.)No orphans: the child runs in its own process group. At
--deadline(default 570 s, under trap.yaml's default 600 s timeout) the shape cancels (ACPsession/cancel), then kills the whole group. SIGINT/SIGTERM do the same before the shape exits with 128+signal.One exit-code table, the same for all three shapes:
0answer ·20refusal ·21max_tokens ·22max_turn_requests ·23agent error (crash, protocol error, auth failure, a reply that is not an answer) ·24config error (bad args, model/option not offered, unsupported input, command not found) ·124deadline. Every failure prints one[trap]line on stderr, never a traceback.tests/test_shapes_backstop.pyfeeds garbage at every boundary to hold this.ACP details (from a live probe on 2026-09-12)
initializewithprotocolVersion: 1and no fs/terminal capabilities.session/newgets an absolutecwdandmcpServers: [].--modelmust exactly match a value the agent lists for itscategory == "model"option. For claude-acp these are aliases (sonnet,haiku,opus[1m]…), and the running values of all options are logged._meta.claudeCode.options.settingSources = ["project"], so only the temp dir's.claude/loads, not the runner's CLAUDE.md, skills, hooks or plugins.--skill <dir>installs into that.claude/skills/.CODEX_CONFIGredirects to the cost proxy only under an API-key login. Under a ChatGPT login the redirect 401s, so the run goes unmetered instead.allow_once, elsereject_once, elsecancelled— neverallow_always.messageId, not every chunk joined. A turn only counts as an answer if the agent did not report zero model use. The probe saw codex answer a 401 withend_turnand the error text as its message. Earlier messages and tool calls go to the case's stderr, which stays local.Model-direct details
max_tokens = 16000; OpenAI-compatible providers get no output cap. Thinking and effort stay at API defaults and are logged.<PROVIDER>_BASE_URL, so undertp runit passes through the cost proxy and is metered. This is tested end to end against a loopback vendor.24, since model-direct sends the question only.Changes outside
src/trap/shapes/src/trap/cost/providers.py: a publicprovider_config(name), appended.src/trap/runner/leaks.pyholds the answer-leak check.src/trap/runner/layout.pygainstask_root, which the runner and the check share so both resolve the same roots.src/trap/errors.pyholdsConfigError, moved to a leaf module and re-exported fromtrap.loader.errors.src/trap/cli/__init__.pyruns the check first and escapes config-error text for Rich.src/trap/cli/__init__.py: the hiddenshapesub-app, which passes its arguments straight through, andpretty_exceptions_show_locals=False.tests/conftest.py:case_capture,process_gone, andsignal_handlers_unchangedhelpers.docs/guides/built-in-shapes.md;## tp shapeindocs/reference/cli.md;writing-solution.md;docs/manifest.json;CODE_MAP.mdedgescli --> shapes,shapes --> cost.No new runtime dependencies: JSON-RPC over stdio is hand-written, and
httpxwas already declared.Tests
tests/fake_acp_agent.py) and loopback HTTP vendors. No npx, no network, and nothing depends on the developer's~/.codex,~/.claudeor API keys.ruff check,ruff format --check,lint_docs.py,pyright src/(0 errors), andpytest --cov— 1167 passed, 15 skipped at 100.00% branch coverage.Not yet done
claude-acpcase undertp run(about $0.05), to confirm answer, cost and cleanup against the real agent.🤖 Generated with Claude Code