@sema-agent/core 7.23.4 → 7.23.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/agents/list-agents-tool.d.ts +5 -1
  3. package/dist/agents/list-agents-tool.js +14 -2
  4. package/dist/brain/anthropic.js +3 -2
  5. package/dist/brain/circuit-breaker.js +3 -3
  6. package/dist/brain/degrading.js +3 -5
  7. package/dist/brain/errors.d.ts +35 -18
  8. package/dist/brain/errors.js +49 -31
  9. package/dist/brain/failover.js +2 -3
  10. package/dist/brain/open-responses.js +6 -5
  11. package/dist/brain/openai.js +6 -5
  12. package/dist/brain/route-adjudicator.d.ts +26 -4
  13. package/dist/brain/route-adjudicator.js +26 -0
  14. package/dist/brain/stream-engine.js +8 -2
  15. package/dist/core/checkpoint-store.d.ts +4 -6
  16. package/dist/core/checkpoint-store.js +2 -2
  17. package/dist/core/permission-rule-model.js +8 -5
  18. package/dist/core/runner/prepare-delegation-surface.d.ts +3 -0
  19. package/dist/core/runner/prepare-delegation-surface.js +1 -0
  20. package/dist/core/runner/prepare-hands-readface.d.ts +4 -1
  21. package/dist/core/runner/prepare-hands-readface.js +4 -0
  22. package/dist/core/runner/prepare-task.js +2 -2
  23. package/dist/core/runner/run-leg.js +2 -13
  24. package/dist/core/thrown-value.d.ts +30 -0
  25. package/dist/core/thrown-value.js +23 -1
  26. package/dist/core/tool-catalog-entries.js +1 -1
  27. package/dist/core/tool-model-gate.js +3 -15
  28. package/dist/core/tools.js +7 -7
  29. package/dist/core/untrusted-text.d.ts +6 -0
  30. package/dist/core/untrusted-text.js +1 -0
  31. package/dist/core/workflow-run-store-contract.js +13 -0
  32. package/dist/core/workflow-run-store.d.ts +15 -0
  33. package/dist/core/workflow-run-store.js +1 -0
  34. package/dist/engine/loop/agent-loop.js +10 -24
  35. package/dist/tools/fs/bash-program-position.d.ts +23 -0
  36. package/dist/tools/fs/bash-program-position.js +11 -0
  37. package/dist/tools/fs/bash-readonly-classifier.d.ts +30 -7
  38. package/dist/tools/fs/bash-readonly-classifier.js +31 -10
  39. package/dist/tools/fs/fs-bash.d.ts +30 -1
  40. package/dist/tools/fs/fs-bash.js +123 -54
  41. package/dist/tools/fs/read-deny.d.ts +14 -0
  42. package/dist/tools/fs/read-deny.js +5 -0
  43. package/dist/tools/fs/safety.d.ts +1 -1
  44. package/dist/tools/fs/safety.js +2 -1
  45. package/dist/tools/monitor.d.ts +5 -0
  46. package/dist/tools/monitor.js +7 -0
  47. package/package.json +1 -1
  48. package/wire-changes/core@7.23.5.json +133 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.23.5 — 2026-09-19
4
+
5
+ ### `ListAgents` states what a row IS, instead of calling every non-live agent `finished` (#917; P2; widening; @server @cli @client-core)
6
+ - **Before.** Both agent lanes of the listing read `status === "running" || status === "pending" ? "running" : "finished"`, so a `parked`, `failed`, `killed` or `cancelled` agent was reported to the model as **finished** — the word the tool's own note attaches the continuable rule to. The roster lane answered `unknown` only when there was NO local row at all; a row in a non-successful terminal was folded like the rest. The model-facing twin of the "an absent answer folds into a successful terminal" family.
7
+ - **After.** One table over the task-status union, shared by both lanes (`AGENT_ROW_STATE`): two folds about LIVENESS — `pending` is queued and certainly not finished, `completed` is the one status that earns `finished` — and passthrough for everything else, so `parked` / `failed` / `killed` / `cancelled` travel as themselves. It is a `Record` over the whole union, so a new task status reds `tsc` here instead of quietly inheriting the old else-arm. The registry's status is an OPEN enum on the wire, so a word this engine does not know now answers `unknown`, never `finished`. The `(N finished agent(s): …)` note counts only real completions.
8
+ - **Reach — widening.** `ListAgentsRow.state` gains `parked` / `failed` / `killed` / `cancelled`; nothing is removed or renamed. A consumer switching exhaustively over the union gains four arms; a consumer testing `state === "finished"` was being told the wrong thing for four of the seven statuses and is now correct. The peer-session lane (`busy` / `idle` / `offline`) is untouched. `test/backlog917-list-agents-state.test.ts` pins one row per task status on both lanes, the unknown-word arm, and the peer lane as a negative control.
9
+
10
+ ### Every inspector of a thrown value reads through the one guarded reader (#935; P2; no wire key)
11
+ - **Before.** `readMember` (`src/core/thrown-value.ts`) is already the engine's rule for reading ONE member of an untrusted value, but six inspectors had not taken it. `classifyConnectFailure` read `.code` / `.cause` / `.errors` bare — from inside the connect loop's catch block, so a throwing accessor raised there; `isAbortError` tested `instanceof` and read `.name` bare **inside a promise rejection handler nobody awaits**, which is the unhandled-rejection shape that takes the process down; the loop's `createErrorToolResult` and `defineTool`'s rewrap read a thrown value's `details` / `errorKind` bare (and the former SPREAD the bag, evaluating every getter behind it); `describeNetworkError` / `hasStaleConnectionCode` / `isAbortSemanticsError` wrapped their whole walk in ONE coarse `try`, where a single hostile link cost every layer below it; `tool-model-gate`'s rebrand hand-built its own guard and kept a dead second one around the already-total `thrownText`.
12
+ - **After.** All of them read through `readMember`, plus two new shared primitives rather than a seventh hand-built guard: `readOwnMembers` (the plural — copies a bag member by member instead of spreading it, and answers `undefined` for anything that is not one) and the now-exported `isErrorLike` (the realm-independent error brand test, so an error minted in a vm context or an injected transport is still walked). Rules: −4 coarse `try` walks, −2 hand-built guards, and two frozen rows leave the `src/brain` silent-arm ratchet (banked, not held as headroom).
13
+ - **The class, swept, not the six reported points.** An independent review found the same read hand-rolled in four more places — `circuit-breaker.ts`, `failover.ts` and `degrading.ts` (twice) each spelled `e instanceof Error && e.name === "AbortError"` on a value an injected brain threw, inside an async IIFE with **no `.catch`**, where a raise is an unhandled rejection. They now share one guarded reader, `abortLikeName`; WHICH names count stays at each site, because that is policy and it differs (the breaker deliberately excludes `TimeoutError`). Its sibling `brainErrorCodeOf` replaces the one `instanceof BrainError` in the same IIFE. Side effect, measured by the same review: a cancellation minted in another realm used to fail `instanceof` and be counted by the circuit breaker as provider ill-health.
14
+ - **Finer, not just safer.** A member that refuses to be read now costs THAT member: a hostile `code` no longer loses the deterministic verdict sitting on `cause`, and a hostile `details` no longer loses the `errorKind` beside it. The aggregate leg list is read the same way, down to its LENGTH and each index — `Array.isArray` answers true for a Proxy over an array, so the bare `.length` behind it ran that proxy's trap and threw out of the classifier, on the path that decides how many times a connect failure is retried (measured; the call ended at one attempt where the budget said three). Values that will not say what they are still answer the fail-safe verdict — indeterminate for the connect classifier, "not a cancellation" for the abort tests.
15
+ - **Reach.** No wire key, no code word, no text change for any readable value (each site's negative control pins the previous bytes). One text change where the value was hostile: a transport rejection whose `name` accessor throws used to surface at the terminal as the ACCESSOR's words, unclassified (measured: `"hostile name"` in place of the rejection); it now surfaces as the rejection's own message with its brain code. `test/backlog935-guarded-inspectors.test.ts` drives the three hostile shapes (null prototype / throwing getter / revoked proxy) through every site.
16
+
17
+ ### A request the runtime refused to BUILD is no longer a retryable network failure (#933; P2; @server @cli @client-core)
18
+ - **Before.** `fetch` answers with a `TypeError` for two unrelated things — a network error, and arguments it will not build a request from — and the connect loop folded both onto one arm. A gateway base URL carrying userinfo (`https://user:pass@host`) makes the runtime throw `TypeError("Request cannot be constructed from a URL that includes credentials: …")` with no `code` and no `cause`; the connect classifier read that empty error graph as "nothing I can judge", answered indeterminate, and the call then spent its entire backoff ladder re-failing identically before a packet was ever sent (measured downstream at ~3 minutes per call, every call).
19
+ - **After — one rule, and a STRUCTURAL one.** `classifyFetchThrow` says whether a throw out of `fetch` was about the NETWORK or about the REQUEST, by the same law the neighbouring connect classifier states (never by message text): a throw that reached the network carries the transport's own error as `cause`, while one the runtime refused to BUILD has no cause at all or nests only a parse code. Measured over ten shapes — userinfo / unparseable URL / unsupported method / invalid header name / GET-with-body on one side, refused / DNS / pre-header reset / TLS-onto-plaintext / mid-body destroy on the other. An argument refusal leaves through the same door a pre-flight refusal does: `BrainError("invalid_request")`, no retry, the runtime's own sentence carried (it names the defect) with every authority's userinfo removed WHOLE first — it quotes a URL, and the general redactor keeps a short or unprefixed username. No `apiError` mark — nothing reached the wire, so an operator is not pointed at the provider. Every non-`TypeError` rejection (an injected transport's own class, the engine's connect-timeout `Error`) stays on the transport arm and still climbs the ladder.
20
+ - **And the bad value is refused where it ARRIVES.** One assertion on the request root, at the seats that accept one: it must be an ABSOLUTE http(s) URL and it may not carry the credential. `createOpenAIBrain` / `createAnthropicBrain` / `createOpenResponsesBrain` refuse a bad `config.baseUrl` synchronously at construction, and `Model.baseUrl` — which no factory can see — is refused at request build, before the transport is entered and before the pairing law speaks (so an unservable root is diagnosed as one, not as a credential mismatch). The scheme half earns its place by measurement: `file:` and `ftp:` reject with `TypeError("fetch failed")` over a causeless "unknown scheme" — the exact shell a refused connection uses — so nothing downstream can tell that settled defect from an outage, and it bought the whole ladder. Refusing is the only honest answer for the credential half too: silently stripping the userinfo would send an UNAUTHENTICATED request to the host the operator thought they had authenticated to. Rules: the three brains' copies of the coalesce-and-strip root line fold into one `resolveRequestRoot` (−2), and the two bad-root refusals are one assertion at one seat (+0).
21
+ - **Reach.** No wire key added, removed or re-typed. One class of failure changes code (`network` → `invalid_request`) and arrival time (one attempt instead of a ladder); a host that constructs brains from operator-supplied config must be ready for a synchronous throw. Stated cost in `docs/KNOWN-LIMITS.md` (KL-933a). `test/backlog933-unbuildable-request.test.ts` pins both halves against the real runtime, with the real-socket `ECONNREFUSED` negative control.
22
+
23
+ ### Workflow list rows state WHY a failed run failed (#932; P2; additive; @server @cli @client-core)
24
+ - **Before.** `WorkflowRunSummary` — the shared projection every `WorkflowRunStore` backend hands back from `listByScope` — already carried the three disclosures that qualify a row which LOOKS clean (`agentFailures`, `budgetOvershoot`, `timeoutInterruption`), but not the run's own `errorCode`. So a `/workflows` panel reading rows saw one word, `failed`, for a refused spawn, a blown token ceiling, a stalled run and a body that simply threw — and had to `get` the full run per failed row (or detour through a `TaskOutput` card) to say which.
25
+ - **After.** `WorkflowRunSummary.errorCode?: string` is projected VERBATIM from `WorkflowRun.errorCode`: same word, same vocabulary, no second attribution rule (the thrown-code → first-failed-agent → absent ladder still runs once, where the run is written). Present exactly when the run carries one; absent reads "no code on the record", never "the run succeeded". Because the projection is shared, every backend answers alike — the importable `workflowRunStoreContract` now asserts the round-trip, the absent key on a codeless failure, and the absent key on a completed run, so a service-side store that keeps rows in columns is told it needs one.
26
+ - **Reach.** Additive: no key removed, none re-typed, every pre-existing member byte-identical. Consuming is optional. `test/backlog932-workflow-summary-error-code.test.ts` pins the projection on both in-tree backends.
27
+ ### Permission rules — the rule lane reads a quoted shell operator as argument text (#931; P2; @cli @server)
28
+ - **Before.** `parseRuleText("Bash(awk '{print $1}' f)", "allow")` answered `invalid.not_simple_command`, and so did `Bash(grep 'x$' f)`, `Bash(cat '$F')` and `Bash(grep "a|b" f.log)`. The card OFFERED those rules and the store refused them: the offer round trip is fail-closed, so the "don't ask again" option simply vanished. #921 gave the whole-string classify gate a quote mask; the rule lane's own per-segment floor (`parseLeadingCommandName`) stayed quote-BLIND and refused every operator character wherever it stood.
29
+ - **After.** The lane calls that floor through ONE option set (`RULE_LANE_FLOOR`) carrying `quotedOperatorsAreText: true` — the same `quoteMask` sieve, not a second reading of quoting. The per-reader `quotedOperatorsAreText` axis on the lane's `LaneReading` is gone (both readings always took the same answer): rules −1.
30
+ - **The matcher's fail-closed claim is now asked out loud.** `admitsUnder` refuses a stored BODY whose quoting is unbalanced, which its own contract already claimed ("a body this lane cannot read matches nothing"). While the floor refused a quoted operator that was true by accident — an unbalanced body could not reach a command that parsed. Reachable only through corrupted or foreign store bytes (no entrance mints one), so behaviour is unchanged for every rule this engine wrote.
31
+ - **REACH, and it is wider than the exact form (@cli @server).** A PREFIX rule widens with the floor: `Bash(grep:*)` now admits `grep 'x|y' f`, `grep 'a;b' f`, `grep '$x' f` and `grep 'x&y' f`, each of which asked before. A stored prefix that names a program therefore reaches every quoted-operator spelling of that program's arguments. Still refused, quoted or not: `"$x"` (double quotes do not suppress expansion), `'$(id)'`, a backtick, a line break, a redirection, and `'x'|'y'` (the pipe is unquoted). A deployment that audits stored rules should re-read its prefix rows against this reach.
32
+ - **What did NOT move.** The whole-string gate one step above is untouched and is what keeps this exact: an unquoted or double-quoted `$`, a backtick, a subshell, an escape, a line break and (on the matching/minting reading) a redirection still refuse the WHOLE text, quoted or not, so the only operator characters that can reach a segment are ones bash itself reads as literal text. Unbalanced quoting turns the mask off for the whole string (fail-closed). The interpreter-prefix refusal (`Bash(awk:*)`) and the DENY/ASK direction (its own lexer, `programRunReachOf`) are unmoved — the "deny reads more commands than allow, never fewer" asymmetry still runs the safe way.
33
+
34
+ ### Closed sets — both shell-decline SEAT TABLES declare a row per member, behind a fence (#924; P3)
35
+ - **Before.** Which {@link ShellScanDecline} a seat reads as read evidence was a `Set`, twice: the read-boundary seat's was DERIVED from the closed set (`new Set(SHELL_SCAN_DECLINES)`) and the classify seat's was an empty literal (`new Set()`). A THIRD member added to the set would be picked up by one seat automatically and missed by the other — silently, and in the WIDENING direction (a MANDATED card becoming an ordinary one). No fence, no gate and no reviewer had a handle on it.
36
+ - **After.** Both are row-per-member `as const` tables beside the set they range over (`bash-program-position.ts`), each with an `AssertAllKeysHandled` fence, registered in `docs/CLOSED-SETS.md` so the scan leg (`npm run gate:closed-sets`) reads their keys against the set's members in both directions. The two `Set`s the seats' readers take are DERIVED from the rows, so neither can say what its table does not. "Empty by the argument" is now a row saying `false`. Zero behaviour change: the derived sets are byte-for-byte what they were.
37
+ - `QuoteOpen` (`bash-lexer.ts`, the quote mask's per-character word) is registered in the same change — the release's wire-change manifest saw it as a closed set and the registry did not.
38
+
39
+ ### Approval cards — a deny-hit card names the sensitive-path deny list and the pattern (#939 ①; P2; @cli @server)
40
+ - **Before.** On the full Bash face, `cat .env` under `readDenyPatterns:["**/.env"]` raised a card reading, in full, `tool "Bash" performs an irreversible action — explicit approval required`, `origin:"shell_gate_tighten"`, and nothing else. Nothing said a deny row was what raised it; nothing named the pattern. The SAME table on the structured read faces refuses and names the pattern (#937), so an approver on the shell face answered YES about credential-class bytes with no clue what they were releasing.
41
+ - **After — the read faces' own sentence, all FIVE parts.** The card carries `probeReason` = `reading "<absolute target>" matches the sensitive-path read deny list (pattern "<pattern>"). This list guards credential-class paths and applies regardless of the containment roots.` — the ACTION, the resolved ABSOLUTE target, the deny table by name, the PATTERN verbatim, and one sentence of why. It composes the SAME two pieces the refusing faces compose (`sensitivePathDenyList` + `SENSITIVE_PATH_DENY_SCOPE`, `read-deny.ts`), now the ONE naming for all four composers; every existing refusal text is byte-unchanged. Only the disposition differs, and it must: this card is approvable, so "is refused" would be a lie on it.
42
+ - **The finding is the SEGMENT's, not the command's.** A compound whose one dirty segment is the hit (`head inside.txt && cat .env`) names THAT segment's path and pattern, in either segment order — a card saying only "this command hits the deny list" leaves the approver reading two segments with no way to tell which half is the problem.
43
+ - **Where it rides, and why.** `probeReason`, never the ask's `message`: the message is what the auto-mode classifier is shown, and a probe must not own an input to the decider that can clear its own ask. The gate caps that string at `PROBE_REASON_MAX` by truncating the TAIL, so the mint elides a long operand in its MIDDLE instead — every part survives and the elision is visible. (`PROBE_REASON_MAX` moves to the leaf neutralizer module beside its `PROBE_CAUSE_PATH_MAX` sibling, re-exported from `checkpoint-store` so the public spelling is unchanged: the mint, the gate and the persist boundary now read one constant.) The account is minted ONCE (`probeAccountOf`) and spread at every arm a shell seat answers a verdict from — including the backgrounded arm and the poll-loop arm, which carried no account at all. The recursive-reach `cause` rides the same mint, unchanged.
44
+ - **@cli @server** — `AskRequest.probeReason` / `RiskDescriptor.probeReason` now carry a value on this family where they carried none. No new field, no new code, no wire key. Consumers already rendering `probeReason` get the sentence for free; a consumer that renders nothing is unchanged.
45
+ - **The finding is read from whichever pass found it.** When the walk cannot NAME the program (`env --`, `nice -x 5`, `watch`, `sudo -u`), when the operands arrive on a STREAM (`… | xargs …`), or when the grammar gate refused the command whole (`{ cat <deny row>; }`, `ca''t <deny row>`), the compound verdict carries no deny fields at all — the row is found by the containment gate's second pass, which computed the path and the pattern and threw both away. `OutOfRootReadGateVerdict.denied` now carries the finding, and the card reads it from there: the shapes an approver can least read for themselves are no longer the silent ones. A shape whose row NO pass could read stays bare — its mandate is the honest weaker one, and a card naming a pattern there would assert what nothing measured.
46
+ - **The TRAVERSAL mandate names its table too.** `recursiveReadPaths` (`grep -r`, `diff -r`) is minted only beside a wired judge WITH entries, so it is a deny-table mandate as much as a match is — and it carried no sentence at all. It cannot name a matched row (the walk is zero-I/O and does not enumerate the subtree, so claiming a hit would be false for the same command in a tree that holds none), so it names what IS true: the ACTION, the resolved ABSOLUTE root, the table by name, the PATTERNS in force, and the clause that makes it a mandate. Rows are dropped whole and counted, never cut into a pattern nobody wrote. A match beside a traversal reports the match.
47
+ - **The table's REACH clause leaves the capped sentence.** It is the same 88 characters on every card of this family — fixed text, not a finding — and inside the 200-character cap it was spending more than half the budget: with the engine's OWN built-in deny table (eight rows longer than 22 characters, the longest 47) and a real project root, BOTH variable parts were elided and "the pattern verbatim" stopped being true of the engine's own rows. Composing two pieces instead of three takes the room for them from 45 characters to 133. The REFUSING faces are uncapped and keep the full sentence.
48
+ - **Not moved:** who may answer the card. The ask is still MANDATED and still approvable, a YES still runs the command, a NO still refuses, and the read-only face still REFUSES outright. Whether the full face should refuse rather than ask (#939 ②) is a separate ruling.
49
+
50
+ ### Read boundary — `Monitor` runs the same execute-time canonical recheck the two `bash` faces run (#938; P2; @server @cli)
51
+ - **Before.** `Monitor` carried an approval-time shell seat like every other shell face (`installShellSeat("Monitor")`), and that seat judges the WRITTEN spelling. Nothing re-judged where the KERNEL would take those words: `env.spawnBackground` was reached directly. With a read boundary declared, `cat <symlink out of the roots>` and `cat <glob whose expansion is denied>` were refused on `Bash` and UNJUDGED on `Monitor` — and a Monitor command runs unattended for as long as the watch lasts, streaming what it reads into the conversation.
52
+ - **After — a MOUNT, not a second leg.** The closure `createBashTool` built inline is now `createShellCanonicalRecheck`, built ONCE at the hands phase (the one place the boundary declaration lives) and handed to the Monitor mount two phases later. Its contract, scope, over-fires and blind spots are the leg's own and unchanged. A refusal is the whole call result: a watch that must not read is a watch that must not START, so nothing registers, nothing streams, and there is no task to stop.
53
+ - **The card names the tool that refused.** The leg is shared; the refusal is not anonymous. `Monitor`'s canonical refusals say `Error (Monitor):` where the `bash` faces say `Error (Bash):` — a model told the wrong tool refused retries on that tool, the one repair the refusal must not suggest. Both `bash` faces' text is byte-unchanged.
54
+ - **`Monitor` takes the same option `createBashTool` takes** (`readBoundary`) and BUILDS the leg itself, so a direct library mount cannot be fail-open on one face and fail-closed on the other.
55
+ - **The leg gets the operands the two read faces hand nobody (SECURITY; @server @cli).** Measured: `cat <in-root symlink out of the roots>` is refused at execution on both shell faces — and `env -- cat <the same symlink>` RAN, after an approval, on `Bash` and on `Monitor`. When the walk cannot NAME the program (`env --`, `sudo -u`) or the operands arrive on a stream, both read faces hand this leg NOTHING, so there was nothing to canonicalize. The containment gate's reader scan DOES read those shapes — same operands, same judge — and now publishes them (`OutOfRootReadGateVerdict.checked` / `.undecided`, which it already computed and discarded). The leg's exclusion grew with it and is the load-bearing half: it skips what the boundary RAISED in BOTH voices (an operand outside the roots, and one the deny judge MATCHED lexically), because both were on the card the approver answered and a yes on either runs — the ruled disposition is untouched.
56
+ - **The leg's exemption is PER HALF, not per path (SECURITY).** A yes runs the read the card DESCRIBED and nothing else. Keyed on the path alone, either voice's release silenced BOTH halves and the direction inverted: an in-root name a deny row matches, whose TARGET is outside the roots, raised a DENY card and the yes then ran the out-of-root read — while the same command with the deny row REMOVED was refused, so adding a deny row turned a refusal off. The mirror: `cat <outside>/link` whose target is a deny row raised a CONTAINMENT card and the yes read the guarded file with the deny half never consulted. Each half now carries its own `answered` set, out-of-root operands ride the candidate list like every other (so the deny half sees them), and no candidate is dropped.
57
+ - **Absent boundary ⇒ no recheck and no I/O**, exactly as on the shell faces. The leg REFUSES an escape and never rescues a refusal, so an in-root watch starts exactly as before.
58
+
3
59
  ## 7.23.4 — 2026-09-19
4
60
 
5
61
  ### Read boundary — one deny judge for every read face: the `bash_readonly` shell wires the same seat (#937; P1; design/199 v1 exception retired; @server @cli)
@@ -50,6 +50,10 @@ export interface ListAgentsRow {
50
50
  /** Peer rows only: the listing-minted ref. */
51
51
  ref?: string;
52
52
  address: string;
53
- state: "busy" | "idle" | "offline" | "running" | "finished" | "unknown";
53
+ /** What the row IS, right now. The peer lane has its own three words (`busy`/`idle`/`offline`), the
54
+ * agent lanes carry the task record's own status word (see {@link AGENT_ROW_STATE}), and `unknown`
55
+ * is the honest answer where no record can be read. `finished` means COMPLETED and nothing else —
56
+ * a model reads it as "the work is done" and applies the continuable rule to it. */
57
+ state: "busy" | "idle" | "offline" | "running" | "parked" | "finished" | "failed" | "killed" | "cancelled" | "unknown";
54
58
  }
55
59
  export declare function createListAgentsTool(opts: ListAgentsToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{}>, unknown>;
@@ -9,6 +9,18 @@ import { thrownText } from "../core/thrown-value.js";
9
9
  export const LIST_AGENTS_TOOL_NAME = "ListAgents";
10
10
  export const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
11
11
  export const LIST_AGENTS_MAX_RESULT_CHARS = 10_000;
12
+ const AGENT_ROW_STATE = {
13
+ pending: "running",
14
+ running: "running",
15
+ parked: "parked",
16
+ completed: "finished",
17
+ failed: "failed",
18
+ killed: "killed",
19
+ cancelled: "cancelled",
20
+ };
21
+ function agentRowState(status) {
22
+ return Object.hasOwn(AGENT_ROW_STATE, status) ? AGENT_ROW_STATE[status] : "unknown";
23
+ }
12
24
  export function createListAgentsTool(opts) {
13
25
  return defineTool({
14
26
  name: LIST_AGENTS_TOOL_NAME,
@@ -58,7 +70,7 @@ export function createListAgentsTool(opts) {
58
70
  continue;
59
71
  seenIds.add(t.task_id);
60
72
  const full = opts.registry.getAccessibleTask(t.task_id, view);
61
- rows.push({ kind: "background_agent", name: full?.name ?? t.description ?? t.task_id, address: t.task_id, state: t.status === "running" || t.status === "pending" ? "running" : "finished" });
73
+ rows.push({ kind: "background_agent", name: full?.name ?? t.description ?? t.task_id, address: t.task_id, state: agentRowState(t.status) });
62
74
  }
63
75
  }
64
76
  }
@@ -70,7 +82,7 @@ export function createListAgentsTool(opts) {
70
82
  continue;
71
83
  seenIds.add(e.agentId);
72
84
  const live = opts.registry?.getAccessibleTask(e.agentId, view);
73
- rows.push({ kind: "roster", name: e.name, address: e.agentId, state: live === undefined ? "unknown" : live.status === "running" || live.status === "pending" ? "running" : "finished" });
85
+ rows.push({ kind: "roster", name: e.name, address: e.agentId, state: live === undefined ? "unknown" : agentRowState(live.status) });
74
86
  }
75
87
  }
76
88
  catch {
@@ -7,7 +7,7 @@ import { mintFallbackToolCallId } from "./tool-call-id.js";
7
7
  import { emitBrainTelemetry, reportReasoningWireFacts } from "./status-sink.js";
8
8
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
9
9
  import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, takeHeaderCasefold } from "./request-params.js";
10
- import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
10
+ import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
11
11
  import { MIN_THINKING_TOKENS, budgetCapSkipsThinking, declaredEffortLevels, reasoningBudgetShare, reasoningRequestCarried, resolveEffort } from "./reasoning.js";
12
12
  import { runStreamingBrain } from "./stream-engine.js";
13
13
  function thinkingBudget(maxTokens, share, fixed, hardCap = false) {
@@ -224,6 +224,7 @@ function closeToolUseBlock(acc) {
224
224
  return acc.closedTc;
225
225
  }
226
226
  export function createAnthropicBrain(config = {}) {
227
+ assertServableRequestRoot(config.baseUrl, "config.baseUrl");
227
228
  const doFetch = config.fetchImpl ?? fetch;
228
229
  const cacheBreakpoints = config.cacheBreakpoints !== false;
229
230
  const stream = (model, context, options) => {
@@ -238,6 +239,7 @@ export function createAnthropicBrain(config = {}) {
238
239
  httpLabel: "anthropic",
239
240
  stallTimeouts: options?.stallTimeouts,
240
241
  buildRequest: (overrides) => {
242
+ const root = resolveRequestRoot(model.baseUrl, config.baseUrl, "https://api.anthropic.com");
241
243
  const routeCredential = resolveRouteCredential({
242
244
  optionsApiKey: options?.apiKey,
243
245
  optionsHeaders: options?.headers,
@@ -249,7 +251,6 @@ export function createAnthropicBrain(config = {}) {
249
251
  if (!routeVerdict.ok)
250
252
  throw new BrainError("invalid_request", routeRefusalText(routeVerdict));
251
253
  const apiKey = routeCredential.apiKey;
252
- const root = (model.baseUrl || config.baseUrl || "https://api.anthropic.com").replace(/\/+$/, "");
253
254
  const nonEmptyBlocks = context.systemBlocks?.filter((b) => b.text.length > 0);
254
255
  const system = nonEmptyBlocks?.length
255
256
  ? nonEmptyBlocks.map((b) => ({
@@ -1,5 +1,5 @@
1
1
  import { createAssistantMessageEventStream } from "../internal/llm.js";
2
- import { BrainError, IN_BAND_OUTPUT_CODES, ZERO_USAGE, extractErrorCode } from "./errors.js";
2
+ import { abortLikeName, brainErrorCodeOf, IN_BAND_OUTPUT_CODES, ZERO_USAGE, extractErrorCode } from "./errors.js";
3
3
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
4
4
  import { thrownText } from "../core/thrown-value.js";
5
5
  export const CIRCUIT_OPEN_MARKER = "circuit open for ";
@@ -142,8 +142,8 @@ export function createCircuitBreakerBrain(inner, opts = {}) {
142
142
  }
143
143
  }
144
144
  catch (e) {
145
- const aborted = options?.signal?.aborted === true || (e instanceof Error && e.name === "AbortError");
146
- const code = e instanceof BrainError ? e.code : "network";
145
+ const aborted = options?.signal?.aborted === true || abortLikeName(e) === "AbortError";
146
+ const code = brainErrorCodeOf(e) ?? "network";
147
147
  record(key, aborted ? "http" : code, !bypass);
148
148
  out.push({
149
149
  type: "error",
@@ -1,5 +1,5 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
- import { ZERO_USAGE, extractErrorCode } from "./errors.js";
2
+ import { abortLikeName, ZERO_USAGE, extractErrorCode } from "./errors.js";
3
3
  import { CIRCUIT_OPEN_MARKER } from "./circuit-breaker.js";
4
4
  import { thrownText } from "../core/thrown-value.js";
5
5
  export const DEGRADED_DIAGNOSTIC_TYPE = "degraded";
@@ -108,8 +108,7 @@ export function passthroughStream(brain, model, context, options) {
108
108
  }
109
109
  }
110
110
  catch (e) {
111
- const aborted = options?.signal?.aborted === true ||
112
- (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError"));
111
+ const aborted = options?.signal?.aborted === true || abortLikeName(e) !== undefined;
113
112
  out.push({ type: "error", reason: "error", error: errorMessageOf(model, e, aborted) });
114
113
  }
115
114
  })();
@@ -175,8 +174,7 @@ export function createDegradingBrain(opts) {
175
174
  out.push(current.terminal ?? terminalOf(current.message));
176
175
  }
177
176
  catch (e) {
178
- const aborted = options?.signal?.aborted === true ||
179
- (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError"));
177
+ const aborted = options?.signal?.aborted === true || abortLikeName(e) !== undefined;
180
178
  const errMsg = errorMessageOf(lastAttempted, e, aborted);
181
179
  if (degradeInfo)
182
180
  tagDegraded(errMsg, degradeInfo);
@@ -112,31 +112,48 @@ export declare function stripErrorCodePrefix(errorMessage: string): string;
112
112
  /**
113
113
  * Whether a transport-level connect failure is DETERMINISTIC (see {@link DETERMINISTIC_CONNECT_CODES})
114
114
  * or gives no such verdict. Classification is by `code` along the error graph — never by message text,
115
- * which providers and runtimes reword freely.
115
+ * which providers and runtimes reword freely. (Distinct question from {@link classifyFetchThrow}, which
116
+ * does not classify a failure at all: it reads the runtime's own fixed shell to tell whether a request
117
+ * was ever built.)
116
118
  *
117
119
  * The graph, not just the chain: undici nests the actionable error under `cause`, and a dual-stack
118
120
  * connect nests one `AggregateError` whose `errors[]` hold the per-family results. A code outside the
119
121
  * set ANYWHERE in the graph returns `"indeterminate"`, and every AGGREGATE LEG must reach a
120
- * deterministic verdict of its own — so a hostname whose IPv4 leg is refused while its IPv6 leg timed
121
- * out, or whose second leg says nothing this classifier can read, keeps the full ladder. Position
122
- * decides how silence is read: a WRAPPER with no code (the `TypeError("fetch failed")` shell, an
123
- * `AbortSignal.timeout` DOMException whose `code` is a number) is structural and defers to what it
124
- * wraps, while a LEG with nothing readable is an outcome nobody accounted for and blocks the verdict.
125
- *
126
- * Fail-safe by construction — every uncertain outcome (empty aggregate, an unreadable leg, a graph
127
- * larger than the node budget, no deterministic code found at all) answers `"indeterminate"`, i.e. the
122
+ * deterministic verdict of its own. Position decides how silence is read: a WRAPPER with no code (the
123
+ * `fetch failed` shell, an `AbortSignal.timeout` DOMException whose `code` is a number) is structural
124
+ * and defers to what it wraps, while a LEG with nothing readable is an outcome nobody accounted for and
125
+ * blocks the verdict. Fail-safe by construction — every uncertain outcome (empty aggregate, unreadable
126
+ * leg, a graph past the node budget, no deterministic code at all) answers `"indeterminate"`, i.e. the
128
127
  * pre-existing behavior.
129
128
  */
130
129
  export type ConnectFailureClass = "deterministic" | "indeterminate";
131
130
  export declare function classifyConnectFailure(e: unknown): ConnectFailureClass;
132
- /** RB-386③ — render a transport-level error WITHOUT folding away its cause chain. undici's
133
- * `fetch` rejects with `TypeError("fetch failed", { cause })` where the CAUSE carries the actually
134
- * actionable fact (`connect ECONNREFUSED …` / `getaddrinfo ENOTFOUND …` / `SocketError: other side
135
- * closed` with a `code`); `String(e)` / `e.message` alone collapsed ALL THREE network-failure shapes
136
- * (refused / DNS / mid-stream disconnect) into the same opaque "fetch failed", end to end (BrainError
137
- * detail → TaskResult.errorMessage → bg-agent terminal notification / poll error). Walks the cause
138
- * chain (bounded), appending each layer's `code:`-prefixed message; layers whose message the parent
139
- * already contains are skipped (no duplicate noise). Falls back to the plain message for causeless
140
- * errors — those call sites' output is byte-identical to before. */
131
+ /** What a throw out of `fetch` was about: the NETWORK (retryable — the historic reading of every throw
132
+ * here), or the REQUEST ITSELF, which is deterministic: identical arguments reproduce it every time. */
133
+ export type FetchThrowClass = "transport" | "request_unbuildable";
134
+ /** Classify a throw out of `fetch` — the fold point where a request-shaped defect used to be laundered into
135
+ * a transient network failure and handed to the retry ladder (founding case: a base URL with userinfo
136
+ * throws a `TypeError` with no `code` and no `cause`, so the connect classifier answered indeterminate and
137
+ * the call spent its whole backoff budget on a request the runtime never built).
138
+ *
139
+ * ONE question — is there TRANSPORT EVIDENCE? — in its two forms: the runtime's own shell sentence, or a
140
+ * `cause` that is a transport fact rather than a parse verdict. A `TypeError` with neither was never built.
141
+ * Measured over ten real shapes; non-`TypeError` stays on the transport arm and an unreadable value answers
142
+ * `transport`, the pre-existing behavior. Stated cost: KL-933a. */
143
+ export declare function classifyFetchThrow(e: unknown): FetchThrowClass;
144
+ /** The cancellation NAME an untrusted rejection states, or `undefined`. Five sites hand-rolled
145
+ * `e instanceof Error && e.name === "AbortError"` on a value an injected brain threw, each able to
146
+ * raise inside an async IIFE with no `.catch`. One guarded, realm-independent read here; WHICH names
147
+ * count stays at each site, because that is policy and it differs (the breaker excludes `TimeoutError`). */
148
+ export declare function abortLikeName(err: unknown): "AbortError" | "TimeoutError" | undefined;
149
+ /** The brain code an untrusted rejection carries, or `undefined`. Reads the WORD against the code list
150
+ * rather than testing `instanceof BrainError`, which walks a chain a revoked proxy traps — and the one
151
+ * site that did so sits in an async IIFE with no `.catch`. */
152
+ export declare function brainErrorCodeOf(e: unknown): BrainErrorCode | undefined;
153
+ /** Render a transport-level error WITHOUT folding away its cause chain: undici rejects with
154
+ * `TypeError("fetch failed", { cause })` where the CAUSE carries the actionable fact, and the shell
155
+ * alone reads the same for refused / DNS / mid-stream disconnect. Walks the chain (bounded), appending
156
+ * each layer's `code:`-prefixed message and skipping layers the parent's message already contains;
157
+ * a causeless error renders as its plain message. */
141
158
  export declare function describeNetworkError(e: unknown): string;
142
159
  export {};
@@ -1,4 +1,4 @@
1
- import { thrownText } from "../core/thrown-value.js";
1
+ import { isErrorLike, readMember, thrownText } from "../core/thrown-value.js";
2
2
  export const ZERO_USAGE = {
3
3
  input: 0,
4
4
  output: 0,
@@ -82,16 +82,12 @@ const STALE_CONNECTION_CODES = new Set([
82
82
  "StreamSuspended",
83
83
  ]);
84
84
  function hasStaleConnectionCode(e) {
85
- try {
86
- let cur = e;
87
- for (let depth = 0; depth < 5 && cur !== undefined && cur !== null; depth++) {
88
- const code = typeof cur.code === "string" ? cur.code : undefined;
89
- if (code !== undefined && STALE_CONNECTION_CODES.has(code))
90
- return true;
91
- cur = cur instanceof Error ? cur.cause : undefined;
92
- }
93
- }
94
- catch {
85
+ let cur = e;
86
+ for (let depth = 0; depth < 5 && cur !== undefined && cur !== null; depth++) {
87
+ const code = readMember(cur, "code");
88
+ if (typeof code === "string" && STALE_CONNECTION_CODES.has(code))
89
+ return true;
90
+ cur = isErrorLike(cur) ? readMember(cur, "cause") : undefined;
95
91
  }
96
92
  return false;
97
93
  }
@@ -104,25 +100,26 @@ function walkConnectFailure(node, budget) {
104
100
  if (node === undefined || node === null)
105
101
  return "silent";
106
102
  let verdict = "silent";
107
- const code = node.code;
103
+ const code = readMember(node, "code");
108
104
  if (typeof code === "string") {
109
105
  if (!DETERMINISTIC_CONNECT_CODES.has(code))
110
106
  return "indeterminate";
111
107
  verdict = "deterministic";
112
108
  }
113
- if (!(node instanceof Error))
109
+ if (!isErrorLike(node))
114
110
  return verdict;
115
- const members = node.errors;
116
- if (Array.isArray(members)) {
117
- if (members.length === 0)
111
+ const members = readMember(node, "errors");
112
+ const legCount = readMember(members, "length");
113
+ if (members !== undefined) {
114
+ if (typeof legCount !== "number" || !Number.isInteger(legCount) || legCount <= 0)
118
115
  return "indeterminate";
119
- for (const member of members) {
120
- if (walkConnectFailure(member, budget) !== "deterministic")
116
+ for (let i = 0; i < legCount; i++) {
117
+ if (walkConnectFailure(readMember(members, String(i)), budget) !== "deterministic")
121
118
  return "indeterminate";
122
119
  }
123
120
  verdict = "deterministic";
124
121
  }
125
- const cause = node.cause;
122
+ const cause = readMember(node, "cause");
126
123
  if (cause !== undefined && cause !== null) {
127
124
  const below = walkConnectFailure(cause, budget);
128
125
  if (below === "indeterminate")
@@ -135,21 +132,42 @@ function walkConnectFailure(node, budget) {
135
132
  export function classifyConnectFailure(e) {
136
133
  return walkConnectFailure(e, { left: CONNECT_GRAPH_NODE_BUDGET }) === "deterministic" ? "deterministic" : "indeterminate";
137
134
  }
135
+ const FETCH_TRANSPORT_SHELLS = new Set(["fetch failed", "terminated"]);
136
+ const REQUEST_ARGUMENT_CODES = new Set(["ERR_INVALID_URL"]);
137
+ export function classifyFetchThrow(e) {
138
+ if (readMember(e, "name") !== "TypeError")
139
+ return "transport";
140
+ const message = readMember(e, "message");
141
+ if (typeof message === "string" && FETCH_TRANSPORT_SHELLS.has(message))
142
+ return "transport";
143
+ const cause = readMember(e, "cause");
144
+ if (cause === undefined || cause === null)
145
+ return "request_unbuildable";
146
+ const code = readMember(cause, "code");
147
+ return typeof code === "string" && REQUEST_ARGUMENT_CODES.has(code) ? "request_unbuildable" : "transport";
148
+ }
149
+ export function abortLikeName(err) {
150
+ if (!isErrorLike(err))
151
+ return undefined;
152
+ const name = readMember(err, "name");
153
+ return name === "AbortError" || name === "TimeoutError" ? name : undefined;
154
+ }
155
+ export function brainErrorCodeOf(e) {
156
+ const code = readMember(e, "code");
157
+ return typeof code === "string" && BRAIN_ERROR_CODES.includes(code) ? code : undefined;
158
+ }
138
159
  export function describeNetworkError(e) {
139
160
  const top = thrownText(e);
140
161
  const parts = [];
141
- try {
142
- let cur = e instanceof Error ? e.cause : undefined;
143
- for (let depth = 0; depth < 4 && cur !== undefined && cur !== null; depth++) {
144
- const code = typeof cur.code === "string" ? cur.code : undefined;
145
- const msg = thrownText(cur);
146
- const piece = code !== undefined && !msg.includes(code) ? `${code}: ${msg}` : msg;
147
- if (piece.length > 0 && piece !== top && !parts.includes(piece))
148
- parts.push(piece);
149
- cur = cur instanceof Error ? cur.cause : undefined;
150
- }
151
- }
152
- catch {
162
+ let cur = isErrorLike(e) ? readMember(e, "cause") : undefined;
163
+ for (let depth = 0; depth < 4 && cur !== undefined && cur !== null; depth++) {
164
+ const rawCode = readMember(cur, "code");
165
+ const code = typeof rawCode === "string" ? rawCode : undefined;
166
+ const msg = thrownText(cur);
167
+ const piece = code !== undefined && !msg.includes(code) ? `${code}: ${msg}` : msg;
168
+ if (piece.length > 0 && piece !== top && !parts.includes(piece))
169
+ parts.push(piece);
170
+ cur = isErrorLike(cur) ? readMember(cur, "cause") : undefined;
153
171
  }
154
172
  const body = parts.length > 0 ? `${top} (${parts.join(" ← ")})` : top;
155
173
  return hasStaleConnectionCode(e) ? `${body} — possibly a stale connection; a fresh one may succeed` : body;
@@ -1,5 +1,5 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
- import { extractErrorCode } from "./errors.js";
2
+ import { abortLikeName, extractErrorCode } from "./errors.js";
3
3
  import { emitBrainTelemetry } from "./status-sink.js";
4
4
  import { passthroughStream } from "./degrading.js";
5
5
  import { thrownText } from "../core/thrown-value.js";
@@ -65,8 +65,7 @@ export function createFailoverBrain(brains) {
65
65
  }
66
66
  }
67
67
  catch (e) {
68
- const aborted = options?.signal?.aborted === true ||
69
- (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError"));
68
+ const aborted = options?.signal?.aborted === true || abortLikeName(e) !== undefined;
70
69
  const errorMsg = {
71
70
  role: "assistant",
72
71
  content: [{ type: "text", text: "" }],
@@ -6,7 +6,7 @@ import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
8
  import { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
9
- import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
9
+ import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
10
10
  import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
11
11
  import { runStreamingBrain } from "./stream-engine.js";
12
12
  const DEGENERATE_POLL_CHARS = 64;
@@ -217,6 +217,7 @@ function mapTerminalStopReason(incompleteReason, hadToolCalls) {
217
217
  return "stop";
218
218
  }
219
219
  export function createOpenResponsesBrain(config = {}) {
220
+ assertServableRequestRoot(config.baseUrl, "config.baseUrl");
220
221
  const doFetch = config.fetchImpl ?? fetch;
221
222
  const stream = (model, context, options) => {
222
223
  let sentMaxTokens;
@@ -232,6 +233,10 @@ export function createOpenResponsesBrain(config = {}) {
232
233
  httpLabel: "responses",
233
234
  stallTimeouts: options?.stallTimeouts,
234
235
  buildRequest: (overrides) => {
236
+ const root = resolveRequestRoot(model.baseUrl, config.baseUrl);
237
+ if (!root) {
238
+ throw new Error("createOpenResponsesBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
239
+ }
235
240
  const routeCredential = resolveRouteCredential({
236
241
  optionsApiKey: options?.apiKey,
237
242
  optionsHeaders: options?.headers,
@@ -243,10 +248,6 @@ export function createOpenResponsesBrain(config = {}) {
243
248
  if (!routeVerdict.ok)
244
249
  throw new BrainError("invalid_request", routeRefusalText(routeVerdict));
245
250
  const apiKey = routeCredential.apiKey;
246
- const root = (model.baseUrl || config.baseUrl || "").replace(/\/+$/, "");
247
- if (!root) {
248
- throw new Error("createOpenResponsesBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
249
- }
250
251
  if (options?.stop !== undefined && options.stop.length > 0) {
251
252
  throw new Error("createOpenResponsesBrain: options.stop is not expressible in the Open Responses request (the wire form has no stop-sequence field); " +
252
253
  "remove it, or serve this model through the chat-completions lane, which does support it");
@@ -7,7 +7,7 @@ import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
8
  import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
9
9
  import { BrainError } from "./errors.js";
10
- import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
10
+ import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
11
11
  import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
12
12
  import { runStreamingBrain } from "./stream-engine.js";
13
13
  function closeToolCallAccum(acc) {
@@ -281,6 +281,7 @@ export function inferMaxTokensField(modelId) {
281
281
  return "max_tokens";
282
282
  }
283
283
  export function createOpenAIBrain(config = {}) {
284
+ assertServableRequestRoot(config.baseUrl, "config.baseUrl");
284
285
  const doFetch = config.fetchImpl ?? fetch;
285
286
  const stream = (model, context, options) => {
286
287
  let sentMaxTokens;
@@ -295,6 +296,10 @@ export function createOpenAIBrain(config = {}) {
295
296
  httpLabel: "gateway",
296
297
  stallTimeouts: options?.stallTimeouts,
297
298
  buildRequest: (overrides) => {
299
+ const root = resolveRequestRoot(model.baseUrl, config.baseUrl);
300
+ if (!root) {
301
+ throw new Error("createOpenAIBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
302
+ }
298
303
  const routeCredential = resolveRouteCredential({
299
304
  optionsApiKey: options?.apiKey,
300
305
  optionsHeaders: options?.headers,
@@ -306,10 +311,6 @@ export function createOpenAIBrain(config = {}) {
306
311
  if (!routeVerdict.ok)
307
312
  throw new BrainError("invalid_request", routeRefusalText(routeVerdict));
308
313
  const apiKey = routeCredential.apiKey;
309
- const root = (model.baseUrl || config.baseUrl || "").replace(/\/+$/, "");
310
- if (!root) {
311
- throw new Error("createOpenAIBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
312
- }
313
314
  const body = {
314
315
  model: model.id,
315
316
  messages: toOpenAIMessages(context, config.replayThinking, model),
@@ -27,15 +27,15 @@
27
27
  * deployment root — a distinct endpoint with no credential route is a configuration hole, and a
28
28
  * loud refusal with a fix hint beats an unauthenticated request to a third-party host.
29
29
  *
30
- * Fail-closed direction: "no credential leak" outranks "silently works". A deployment that only
31
- * worked because the gateway key HAPPENED to be valid at another entry's host now refuses loudly,
32
- * and every refusal names the entry, both URL halves, and a one-line fix.
30
+ * Fail-closed direction: "no credential leak" outranks "silently works", and every refusal names the
31
+ * entry, both URL halves, and a one-line fix.
33
32
  *
34
- * Bad-value loudness (garbage / contradiction / absence):
33
+ * Bad-value loudness (garbage / contradiction / absence / misplacement):
35
34
  * - garbage — non-string URL/credential inputs throw a `TypeError` here (a programmer error must
36
35
  * not be laundered into a polite verdict); whitespace-only URLs normalize to "" (undeclared).
37
36
  * - contradiction — the torn pair refuses with `route.credential_mismatch` + both halves named.
38
37
  * - absence — `route.credential_missing` with the fix hint, exactly where absence is a hole.
38
+ * - misplacement — a credential in the URL, or a non-http(s) root: {@link assertServableRequestRoot}.
39
39
  */
40
40
  import type { Model } from "../internal/llm.js";
41
41
  import type { RouteAdjudication, RouteCredential, RoutePairingConfig, RoutePairingPosture } from "../internal/llm.js";
@@ -50,6 +50,28 @@ import type { Brain, EngineNotice } from "../core/types.js";
50
50
  * not parse as a URL keeps the raw trimmed spelling — the same fail-closed raw comparison as
51
51
  * before. Non-string input throws — see the module note on garbage. */
52
52
  export declare function normalizeBaseUrl(url: string | undefined): string;
53
+ /**
54
+ * The bad-value refusal every base-URL seat owes: **a request root must be an absolute http(s) URL, and
55
+ * it may not carry the credential itself.**
56
+ *
57
+ * Both halves fail the same way if they are not caught here — as a TRANSPORT failure, indistinguishable
58
+ * from an outage, retried for the whole backoff ladder. Measured: `file:`/`ftp:` reject with
59
+ * `TypeError("fetch failed")` over a causeless "unknown scheme", the exact shell a refused connection uses.
60
+ * Stripping userinfo instead of refusing would send an UNAUTHENTICATED request to the host the operator
61
+ * thought they had authenticated to. Refused where the value ARRIVES — construction for `config.baseUrl`,
62
+ * request build for `Model.baseUrl`. An undeclared root is not this rule's question; the PAIRING law keeps
63
+ * its tolerance for an unparseable spelling ({@link normalizeBaseUrl}). Answers the value it judged.
64
+ */
65
+ export declare function assertServableRequestRoot(url: string | undefined, seat: string): string | undefined;
66
+ /** Remove the userinfo from every authority in free text, WHOLE — the general redactor keeps a short or
67
+ * unprefixed username, and `TOKEN@host` / `TOKEN:x-oauth-basic@host` are the canonical PAT spellings. */
68
+ export declare function stripUrlCredentials(text: string): string;
69
+ /** The request ROOT a brain targets — `Model.baseUrl` else `config.baseUrl` else the lane's default,
70
+ * trailing slashes stripped — with {@link assertServableRequestRoot} applied to both declared
71
+ * halves on the way. The three brains each carried a copy of this coalesce-and-strip line, so a rule
72
+ * about base URLs had three homes; now it has one. Answers `""` when nothing is declared and the lane
73
+ * has no default — whether that is a refusal is the caller's. */
74
+ export declare function resolveRequestRoot(modelBaseUrl: string | undefined, configBaseUrl: string | undefined, laneDefault?: string): string;
53
75
  /** Whether a header bag carries an auth-bearing header (any case). */
54
76
  export declare function hasAuthCarrier(headers: Record<string, string> | undefined): boolean;
55
77
  /**
@@ -1,4 +1,5 @@
1
1
  import { AUTH_CARRIER_NAMES } from "./request-params.js";
2
+ import { BrainError } from "./errors.js";
2
3
  export function normalizeBaseUrl(url) {
3
4
  if (url === undefined)
4
5
  return "";
@@ -16,6 +17,31 @@ export function normalizeBaseUrl(url) {
16
17
  return raw;
17
18
  }
18
19
  }
20
+ export function assertServableRequestRoot(url, seat) {
21
+ if (typeof url !== "string" || url.trim() === "")
22
+ return undefined;
23
+ const trimmed = url.trim();
24
+ const parsed = URL.canParse(trimmed) ? new URL(trimmed) : undefined;
25
+ if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
26
+ const named = parsed === undefined ? "it did not parse as a URL" : `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
27
+ throw new BrainError("invalid_request", `${seat} is not a servable request root (${named}) — it must be an ABSOLUTE http(s) URL. Another scheme, ` +
28
+ `or a spelling that is not a URL at all, fails at the transport in a shape indistinguishable from an ` +
29
+ `outage, so it would be retried for the whole backoff ladder instead of refused here.`);
30
+ }
31
+ if (parsed.username === "" && parsed.password === "")
32
+ return trimmed;
33
+ throw new BrainError("invalid_request", `${seat} carries credentials in its authority (${parsed.protocol}//[redacted-credentials]@${parsed.host}${parsed.pathname}) — ` +
34
+ `a request cannot be built from a URL with userinfo, so every call through this brain would fail: put the ` +
35
+ `credential on the apiKey knob or an auth header and leave the URL bare`);
36
+ }
37
+ export function stripUrlCredentials(text) {
38
+ return text.replace(/([A-Za-z][A-Za-z0-9+.\-]*:\/\/)[^/\s"'@]*@/g, "$1[redacted-credentials]@");
39
+ }
40
+ export function resolveRequestRoot(modelBaseUrl, configBaseUrl, laneDefault = "") {
41
+ const entry = assertServableRequestRoot(modelBaseUrl, "Model.baseUrl");
42
+ const declared = assertServableRequestRoot(configBaseUrl, "config.baseUrl");
43
+ return (entry ?? declared ?? laneDefault).replace(/\/+$/, "");
44
+ }
19
45
  export function hasAuthCarrier(headers) {
20
46
  if (!headers)
21
47
  return false;