Reject a repeated s1a option before the model is built

John Burns

A later --option with the same key used to replace the description a System 1 model would see. The command still looked like two choices. Only the last text was kept, and the process did not say a description had been dropped. On the system1-agents checkout tested here, that replacement does not happen. s1a decide exits 2, names the repeated key, and prints neither description.

That exit is the request gate. It is not a score, and it is not a calibration result. A request that gets past it still has to find a key. A request that names a key still leaves the machine. The useful split is which of those failures you are looking at, because they do not mean the same thing and they do not happen in the same place.

What was on the command line

The checkout was ThinkFlowLab system1-agents at 0ee116559fa2a4e64b5c37b2e6e3bed65aeb266e, the 4 November 2026 merge of pull request 112. The run was 5 November 2026 on Linux x86_64. uv was 0.11.32. uv sync --no-dev selected CPython 3.11.15 and installed the project from that tree. The resolved openjiuwen pin was 0.1.18 at 18b840cff. No git tag was present. importlib.metadata reported the distribution version as 0.1.0, which is the string still written in pyproject.toml. The changelog heading for the repeated-key fix is 0.3.0, dated 30 October 2026. Those two numbers do not identify the same fact. A version query will not tell you whether this gate is in the tree you just synced.

No TYPESAFE_API_KEY and no OPENROUTER_API_KEY were set for the empty-environment cases. No .env was added to the checkout. S1A_HOME pointed at a new directory, so harness logs did not land in a normal home. No model login was used. s1a list was the install check: exit 0, empty stderr, and the nine agent names the tree registers. The decide and probe commands below are the same uv run --no-sync s1a entry. They are not a reimplementation of the parser.

The README, read from that commit, says each --option KEY=DESCRIPTION must use a unique, case-sensitive key, and that repeated keys are usage errors with exit 2, reported before a model loads or credentials are checked. The changelog says the old path silently replaced an earlier description, and that the usage error names the duplicate key without printing either description. This run did not check out an older revision, so it does not reproduce the silent replacement. It does show what the current entry does instead, including two failures the README sentence does not print.

Two exit codes, four different failures

Exit 2 is not one failure. The CLI docstring says a flag that argparse rejects prints a usage block, and that every other non-zero exit is one line on stderr. Both of those shapes were measured, and both can be exit 2.

A repeated key is the one-line shape. Stdout was empty. Stderr was exactly:

--option repeats key 'stand'; use a unique key for each choice

The second description was a marker string that did not appear in stdout or stderr. The same command with both descriptions identical still printed that line and nothing else. The check is the key, not a disagreement between the two texts. An operator who repeats a key by accident, with the same sentence pasted twice, gets the same refusal as an operator who meant to overwrite the description.

--model random is the other exit 2. Argparse printed a usage block and then:

s1a decide: error: argument --model: invalid choice: 'random' (choose from 'jev', 'clm', 'laya', 'laya-served', 'cua')

--model rule produced the same kind of block, with rule in the invalid-choice line. The decision-model factory in this tree knows random and rule as eval baselines. s1a decide does not offer them. Help text for decide lists the same five names the error lists: jev, clm, laya, laya-served, and cua. A local baseline is not a way around a missing key on this command. If you need a decision from this entry, you are on one of those five. jev, clm, and laya-served are HTTP clients. laya and cua load in process, and this run did not install those extras. None of the five is the random or rule slot.

Exit 1 is the request that parsed. With unique keys and no credential, stderr was one line:

[181002] model service config error, reason: no Jev key: set TYPESAFE_API_KEY or OPENROUTER_API_KEY in .env or the environment

A TYPESAFE_API_KEY set to spaces only produced that same line. It did not become an HTTP 401. The loader strips the variable before the client is built, and an empty key is the missing-key error. That matches the comment in the checkout’s config module. It is still worth running, because a file that looks populated can be a blank line, and the decide error will not say “blank.” It will say no key.

A non-empty OPENROUTER_API_KEY set to a non-credential marker, with TYPESAFE_API_URL pointed at a closed local port and S1A_DECISION_TIMEOUT_S set to 1, produced a different exit 1:

[181001] model call failed, reason: decisions connection failed

The marker did not appear in that line. The URL did not appear in that line either. The failure is still the network path: the client was built, and the call did not complete. The same environment, with the option key repeated, did not print 181001. It printed the repeated-key line and exited 2. That is the evidence that the usage check happens before the client is used. It is not evidence from a timer. Every case in the set, including the usage errors, returned in 2798 to 2884 milliseconds. The harness import dominates that interval. Do not treat a fast return as proof that the model was not constructed, and do not treat a three-second return as proof that a socket was opened. Read the sentence.

What still counts as a well-formed option

Case is part of the key. --option stand=keep the hand together with --option Stand=fold the hand did not exit 2. It exited 1 with the no-key line. Stand and stand are two choices. A later step that lowercases keys before comparing them will not see the refusal this command just declined to make. If the decision schema treats those spellings as one action, the schema has to reject the pair before s1a decide is called. This entry will send both.

An option with no equals sign is also exit 2, and it is not the quiet one. --option not-a-key printed:

--option needs KEY=DESCRIPTION, got 'not-a-key'

The whole argument is in the error. A repeated key omits both descriptions. A missing = does not omit the text you passed. Do not put a secret, a customer sentence, or a ticket body in an option that might be missing its separator. The repeated-key path was written not to echo that text. The malformed-option path was not.

An empty description is accepted. --option a= with a second unique key reached the no-key line, not a usage error. A description that itself contains = is also accepted: b=one=two was not reported as a bad option. The split is the first equals sign. Everything after it is the description, including further equals signs and including nothing. The unit test in the tree documents that parse. The CLI run shows the consequence: those forms are not stopped by the request gate. They become part of the question if a key is present.

A state that is not a JSON object stops earlier than the key check, and it stops as exit 2. {nope produced a one-line --state decode error. [1] produced --state must be a JSON object. A @path that does not exist produced a one-line --state error naming that path. None of those lines mentioned a key. None of them is the repeated-option line. If you are scanning logs for the gate, match the repeated-key sentence, not merely exit 2.

Probe flags fail in a different order

s1a probe has its own usage gate, and the order of the checks is the part the changelog line does not spell out. --seed or --json without --order is a usage error. The measured line was:

--seed and --json only apply with --order

That line was printed when the cases file did not exist, and it was printed again when the file did exist. The flag check runs before the file is read. A missing path will not hide a misplaced --seed. Fix the flag first. You will not learn from this error whether the file is there.

A bad --order is the other way around. --order nope on a missing file printed the path error, not the order sentence. The file is read before the order string is parsed. On a one-line file that did parse, --order nope printed:

--order must be reverse, shuffle:N or same:N with 1 <= N <= 50, got 'nope'

--order shuffle:51 on that same file printed the same sentence with shuffle:51 in the got clause. Exit was 2. There was no no-key line. The bound is enforced before a model is built, but only after the cases file has been accepted. A pipeline that wants the order error rather than a file error has to point --order at a file that already parses.

A legal order flag is not a usage error. --order reverse on that one-line file, with no key in the environment, exited 1 with the no-key line. The file was accepted, the order string was accepted, and then the model factory ran. reverse does not by itself prove the option-order check. It proves the request was well formed enough to need a backend. The flip rate described in the changelog was not measured here. No decision was returned.

Verify the request before the model

Run this from a checkout, not from a copied wheel. The console module in this tree exits when pyproject.toml is not beside the installed package’s parent. This run did not install a separate wheel. It used uv run in the tree that still has that file. Sync first if the environment is new. The gate does not need the cua or laya extras. Those extras pull a local model. This check should fail before any of that work.

uv sync --no-dev
uv run s1a decide --state '{"player_total": 18}' \
  --option 'stand=keep the hand' \
  --option 'stand=replacement text' \
  --rules 'stand on 17 or more'
echo "decide_exit:$?"

Success for the gate is exit 2, empty stdout, and the one line that names stand. The second description must not appear. Repeat the command with a credential and a decisions URL already in the environment if that is how the job will run. The exit must stay 2, and the line must stay the repeated-key line. If you see 181001 or 181002, the key was not repeated in the process that just ran. A wrapper that drops duplicate arguments before s1a will hide the bug this gate is for. Run the binary’s own parser.

Then run the accepted-request control with the credentials unset:

env -u TYPESAFE_API_KEY -u OPENROUTER_API_KEY -u TYPESAFE_API_URL \
  uv run s1a decide --state '{"player_total": 18}' \
  --option 'hit=take a card' \
  --option 'stand=keep the hand' \
  --rules 'stand on 17 or more'

The expected line is 181002 and the no-key reason. That result means the options were unique and the state parsed. It does not mean Jev agreed with the rules. Do not point a real key at a public endpoint to finish this check. The connection case in this note used a non-credential marker and a closed local port, and it stopped at 181001. A live key would be a different test, against a service this run did not call.

For probe, keep the two flag errors separate:

uv run s1a probe cases.jsonl --seed 7
uv run s1a probe cases.jsonl --order shuffle:51

The first must print the --seed and --json sentence even if you pass a path you know is missing. The second, on a file that parses, must print the order sentence and must not print the no-key line. --order reverse with no key should reach 181002. If reverse already exits 2, the file or the flag is not the one you think.

s1a decide --model random should be the argparse block, not the one-line usage error. If a future build adds random to the choice list, this control changes, and the note no longer describes that binary. Capture s1a decide --help with the run. The help text in this checkout says to repeat --option with a unique key. It does not say the error omits descriptions, and it does not mention exit 2. The process output is the check. The help line is only the hint.

What the gate does not decide

The package version will not carry this fix. 0.1.0 was the installed distribution version on a tree whose changelog already calls the repeated-key change 0.3.0. There were no tags to resolve the disagreement. Pin the commit you synced, and keep the exit-2 line in the job log. A green uv sync is not a substitute.

The gate also does not say the task is a decision-model task. The fit probe’s verdict, the option-order flip rate, and any comparison with a chat planner need a backend that answers. This run stopped those paths at the missing key on purpose. An earlier note on this site compared a System 1 skill’s review rule with a keyword function and one headless planner turn. That is a different question. The question here is whether the text you think you offered is the text the client will send.

A well-formed request with a real key is outside the gate. So is laya or cua once the extra is installed: those names are legal --model values, and this run did not load them. random and rule remain unavailable on decide even though the factory can build them for an agent eval. If a job needs a local baseline, it is not this subcommand.

Use the repeated-key line as the preflight. Require exit 2 and an empty stdout before any secret is present in the environment. Then require the no-key line on a unique pair with the secret unset, so you know the parser accepted the request you meant to send. Only after both of those are true is a credentialed call a test of the model rather than a test of the command.

Sources

  • ThinkFlowLab system1-agents commit 0ee116559fa2a4e64b5c37b2e6e3bed65aeb266e, 4 November 2026, run on 5 November 2026 with uv 0.11.32 and CPython 3.11.15. s1a decide and s1a probe through uv run --no-sync, credential-free except for the labeled closed-port case. Repository: system1-agents.
  • The same commit’s README sentence on unique, case-sensitive --option keys and exit 2 before a model or a credential check, and CHANGELOG.md section 0.3.0 (30 October 2026) on rejecting a repeated key instead of replacing the description.
  • s1a/cli.py and s1a/probe.py in that checkout, compared with the process output above. The distribution version reported by importlib.metadata was 0.1.0.