Skip to content

Commit 9d7e97a

Browse files
sanil-23claude
andcommitted
feat(scripts): add --check, a preflight for a tau2 company
Every layer this run depends on has failed silently at least once while the bundle was being built, and each looked like a model problem from the transcript alone. `--check` asserts them all and spends no model call: * role servers reachable, with the EXACT tool scope each seat should hold — `triage` acquiring a write tool makes every later result meaningless rather than loud; * desks staffed as intended, and a two-seat desk actually holding two, since `deliberates()` needs two and a one-seat desk never convenes; * every `mcp.json` name registered, enabled, and reachable THROUGH the host, not merely declared — a desk with an unregistered server deliberates confidently with no tools at all; * the credential probing clean, which otherwise surfaces as a 401 on the first turn rather than at boot; * the tau2 state file present — deleting it under a running server leaves every tool call answering `Error executing tool`. It found a real one immediately: a `PUT …/inference` carrying only the key stores an empty `models` map, which shadows the manifest's `[inference.models]`, and the probe went asking for `anthropic/claude-sonnet-5`. README now says to send the table with the key. Also fixes two of my own bugs: `check()` called bare paths instead of company-scoped ones (every company check reported 404 against a healthy host), and a refused connection is now reported as unreachable rather than masquerading as an HTTP status. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d977e9b commit 9d7e97a

2 files changed

Lines changed: 145 additions & 14 deletions

File tree

‎companies/retail_co/README.md‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ curl -X PUT localhost:8099/api/v1/companies/retail-co/inference \
6767
-d "{\"provider\":\"openrouter\",\"base_url\":\"https://openrouter.ai/api/v1\",\"key\":\"$OPENROUTER_API_KEY\"}"
6868
```
6969

70+
**Send the `models` table with the key.** `PUT …/inference` stores the whole
71+
config, and an omitted `models` becomes an empty map that then *shadows* this
72+
bundle's `[inference.models]` — the next turn asks the provider for a default
73+
model nobody chose. Observed: a `PUT` carrying only the key made the probe
74+
request `anthropic/claude-sonnet-5`, which the account's allowed-providers
75+
refused. `--check` catches this.
76+
7077
A company declaring `[inference]` consults its own `inference/key` secret, so
7178
`OPENCOMPANY_INFERENCE_KEY` does **not** stand in for it — the first turn fails
7279
with a 401 from the platform endpoint rather than from OpenRouter. Hosting
@@ -80,7 +87,16 @@ cargo run --features openhuman,hivemind,mcp --bin opencompany -- \
8087
python3 scripts/tau2-sim.py --domain retail --task 0
8188
```
8289

83-
`scripts/tau2-sim.py` repoints the five entries at loopback, replays the
90+
Verify the whole rig before spending a model call — role servers reachable with
91+
the exact tool scope each seat should have, desks staffed as intended, MCP
92+
registered and reachable *through the host*, the credential probing clean, and
93+
the tau2 state present:
94+
95+
```bash
96+
python3 scripts/tau2-sim.py --domain retail --check
97+
```
98+
99+
Exit status is the number of failed checks. Then `scripts/tau2-sim.py` repoints the five entries at loopback, replays the
84100
task's opening message into `triage`, and grades the shared retail database
85101
against tau2's own `evaluation_criteria`. Exit status is the number of tasks
86102
whose end state did not match.

‎scripts/tau2-sim.py‎

Lines changed: 128 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,21 @@
6969
"company": "retail-co",
7070
"entry": "triage",
7171
"state": ".state/retail.json",
72+
# seat -> (port, tools in scope, of which mutating). The counts are the
73+
# contract this whole design rests on: `triage` holding a write tool, or
74+
# `exchanges` reaching the refund tool, means the scoping silently broke
75+
# and every later result is meaningless. `--check` asserts them.
7276
"seats": {
73-
"triage": 8801,
74-
"exchanges": 8802,
75-
"refunds": 8803,
76-
"cancellations": 8804,
77-
"amendments": 8805,
77+
"triage": (8801, 9, 0),
78+
"exchanges": (8802, 9, 1),
79+
"refunds": (8803, 8, 1),
80+
"cancellations": (8804, 8, 1),
81+
"amendments": (8805, 11, 3),
82+
},
83+
"desks": {
84+
"triage": ["triage"],
85+
"order_ops": ["cancellations", "amendments"],
86+
"returns": ["exchanges", "refunds"],
7887
},
7988
"collection": "orders",
8089
"key": "order_id",
@@ -107,10 +116,15 @@
107116
"entry": "triage",
108117
"state": ".state/airline.json",
109118
"seats": {
110-
"triage": 8811,
111-
"booking": 8812,
112-
"changes": 8813,
113-
"refunds": 8814,
119+
"triage": (8811, 8, 0),
120+
"booking": (8812, 9, 1),
121+
"changes": (8813, 11, 3),
122+
"refunds": (8814, 10, 2),
123+
},
124+
"desks": {
125+
"triage": ["triage"],
126+
"booking": ["booking"],
127+
"post_booking": ["changes", "refunds"],
114128
},
115129
"collection": "reservations",
116130
"key": "reservation_id",
@@ -172,6 +186,12 @@ def call(self, method: str, path: str, body: Any = None, timeout: float = 120):
172186
return err.code, json.loads(raw)
173187
except ValueError:
174188
return err.code, raw.decode(errors="replace")
189+
except (urllib.error.URLError, OSError) as err:
190+
# Nothing listening, or the host died mid-run. Reported as 0 rather
191+
# than raising, so `--check` can say "unreachable" instead of dying
192+
# with a traceback — and so it is never mistaken for a 404 from a
193+
# host that IS answering.
194+
return 0, f"unreachable: {err}"
175195

176196
def sign_in(self) -> None:
177197
"""No-op when auth is `none`; otherwise the loopback dev-code flow."""
@@ -337,6 +357,92 @@ def grade(task: dict, state: dict, spec: dict) -> tuple[bool, str]:
337357
return (not problems), "; ".join(problems) or "matches"
338358

339359

360+
def check(host: "Host", spec: dict, domain: str, state_path: Path) -> int:
361+
"""Preflight: assert every layer this run depends on, spending no model call.
362+
363+
Each of these has failed silently at least once while this bundle was being
364+
built, and each looked like a model problem from the transcript alone:
365+
366+
* a role server reading a state file deleted under it — every tool call came
367+
back `Error executing tool`, which reads as the agent using them wrong;
368+
* `mcp.json` declaring server names the runner never registered, so a desk
369+
deliberated confidently with no tools at all;
370+
* a scope quietly widening, which makes a pass meaningless rather than loud;
371+
* no inference key, which surfaces as a 401 on the first turn rather than at
372+
boot;
373+
* a desk with one member where two were intended — `deliberates()` needs
374+
two, so the room never convenes and one seat decides alone.
375+
"""
376+
bad = 0
377+
378+
def ok(label: str, good: bool, detail: str = "") -> None:
379+
nonlocal bad
380+
bad += 0 if good else 1
381+
mark = "ok " if good else "FAIL"
382+
print(f" [{mark}] {label}{(' — ' + detail) if detail else ''}")
383+
384+
print(f"role servers ({domain})")
385+
for seat, (port, want_tools, want_mut) in spec["seats"].items():
386+
url = f"http://127.0.0.1:{port}/mcp"
387+
body = json.dumps({"jsonrpc": "2.0", "id": 1,
388+
"method": "tools/list", "params": {}}).encode()
389+
req = urllib.request.Request(url, data=body, method="POST")
390+
req.add_header("content-type", "application/json")
391+
req.add_header("accept", "application/json, text/event-stream")
392+
try:
393+
with urllib.request.urlopen(req, timeout=20) as resp:
394+
raw = resp.read().decode(errors="replace")
395+
except Exception as err: # noqa: BLE001 — any failure is the same verdict
396+
ok(f"{seat} :{port}", False, f"unreachable ({err})")
397+
continue
398+
tools = len(re.findall(r'"name":"[a-z_]+"', raw))
399+
mut = raw.count("write/mutates")
400+
ok(f"{seat} :{port}", tools == want_tools and mut == want_mut,
401+
f"{tools} tools / {mut} mutating, wanted {want_tools}/{want_mut}")
402+
403+
print("company")
404+
status, desks = host.call("GET", f"{host.scope}/desks")
405+
ok("desks readable", status == 200, f"HTTP {status}")
406+
if status == 200 and isinstance(desks, list):
407+
got = {d.get("id"): sorted(d.get("members") or []) for d in desks}
408+
for desk, members in spec["desks"].items():
409+
ok(f"desk {desk}", got.get(desk) == sorted(members),
410+
f"members={got.get(desk)}, wanted {sorted(members)}")
411+
if len(members) >= 2:
412+
ok(f"desk {desk} can deliberate", len(got.get(desk) or []) >= 2,
413+
"needs two seats")
414+
415+
print("mcp wiring")
416+
status, servers = host.call("GET", f"{host.scope}/mcp/servers")
417+
if status != 200 or not isinstance(servers, list):
418+
ok("server list", False, f"HTTP {status}")
419+
else:
420+
by_name = {x.get("name"): x for x in servers}
421+
for seat in spec["seats"]:
422+
name = f"tau2-{domain}-{seat}"
423+
row = by_name.get(name)
424+
live = bool(row and row.get("enabled"))
425+
ok(f"{name} enabled", live,
426+
"" if live else ("not registered" if row is None else "declared but disabled"))
427+
if row and row.get("enabled"):
428+
st, tools = host.call("GET", f"{host.scope}/mcp/servers/{urllib.parse.quote(name)}/tools")
429+
ok(f"{name} reachable from the host", st == 200 and isinstance(tools, list),
430+
f"HTTP {st}")
431+
432+
print("inference")
433+
status, body = host.call("POST", f"{host.scope}/inference/test", {}, timeout=120)
434+
fine = status == 200 and isinstance(body, dict) and body.get("ok")
435+
ok("credential probes clean", bool(fine),
436+
(body or {}).get("error", f"HTTP {status}") if not fine else "")
437+
438+
print("tau2 state")
439+
ok(f"{state_path} present", state_path.exists(),
440+
"delete it AND restart the servers to reseed — they seed at boot")
441+
442+
print(f"\n{'all checks passed' if not bad else str(bad) + ' check(s) failed'}")
443+
return bad
444+
445+
340446
def main() -> int:
341447
ap = argparse.ArgumentParser(
342448
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
@@ -345,6 +451,9 @@ def main() -> int:
345451
ap.add_argument("--base", default="http://127.0.0.1:8080", help="running `opencompany serve`")
346452
ap.add_argument("--tau2", type=Path, default=Path("../opencompany-tau2"),
347453
help="the opencompany-tau2 checkout (tasks + shared state)")
454+
ap.add_argument("--check", action="store_true",
455+
help="verify servers, desks, mcp wiring, credential and state, "
456+
"then exit — spends no model call")
348457
ap.add_argument("--task", help="one task id")
349458
ap.add_argument("--tasks", help="comma-separated task ids")
350459
ap.add_argument("--host", default="127.0.0.1", help="host the role servers bound to")
@@ -365,6 +474,15 @@ def main() -> int:
365474
ap.error(f"unknown domain {args.domain!r}; known: {', '.join(DOMAINS)}")
366475
spec = DOMAINS[args.domain]
367476

477+
state_path = args.tau2 / spec["state"]
478+
host = Host(args.base, spec["company"])
479+
480+
if args.check:
481+
host.sign_in()
482+
for seat, (port, _t, _m) in spec["seats"].items():
483+
host.register_mcp(f"tau2-{args.domain}-{seat}", f"http://{args.host}:{port}/mcp")
484+
return check(host, spec, args.domain, state_path)
485+
368486
ids = []
369487
if args.task:
370488
ids = [args.task]
@@ -379,11 +497,8 @@ def main() -> int:
379497
if missing:
380498
raise SystemExit(f"no such {args.domain} task(s): {', '.join(missing)}")
381499

382-
state_path = args.tau2 / spec["state"]
383-
384-
host = Host(args.base, spec["company"])
385500
host.sign_in()
386-
for seat, port in spec["seats"].items():
501+
for seat, (port, _tools, _mut) in spec["seats"].items():
387502
name = f"tau2-{args.domain}-{seat}"
388503
status, body = host.register_mcp(name, f"http://{args.host}:{port}/mcp")
389504
if status >= 300:

0 commit comments

Comments
 (0)