@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.
- package/CHANGELOG.md +56 -0
- package/dist/agents/list-agents-tool.d.ts +5 -1
- package/dist/agents/list-agents-tool.js +14 -2
- package/dist/brain/anthropic.js +3 -2
- package/dist/brain/circuit-breaker.js +3 -3
- package/dist/brain/degrading.js +3 -5
- package/dist/brain/errors.d.ts +35 -18
- package/dist/brain/errors.js +49 -31
- package/dist/brain/failover.js +2 -3
- package/dist/brain/open-responses.js +6 -5
- package/dist/brain/openai.js +6 -5
- package/dist/brain/route-adjudicator.d.ts +26 -4
- package/dist/brain/route-adjudicator.js +26 -0
- package/dist/brain/stream-engine.js +8 -2
- package/dist/core/checkpoint-store.d.ts +4 -6
- package/dist/core/checkpoint-store.js +2 -2
- package/dist/core/permission-rule-model.js +8 -5
- package/dist/core/runner/prepare-delegation-surface.d.ts +3 -0
- package/dist/core/runner/prepare-delegation-surface.js +1 -0
- package/dist/core/runner/prepare-hands-readface.d.ts +4 -1
- package/dist/core/runner/prepare-hands-readface.js +4 -0
- package/dist/core/runner/prepare-task.js +2 -2
- package/dist/core/runner/run-leg.js +2 -13
- package/dist/core/thrown-value.d.ts +30 -0
- package/dist/core/thrown-value.js +23 -1
- package/dist/core/tool-catalog-entries.js +1 -1
- package/dist/core/tool-model-gate.js +3 -15
- package/dist/core/tools.js +7 -7
- package/dist/core/untrusted-text.d.ts +6 -0
- package/dist/core/untrusted-text.js +1 -0
- package/dist/core/workflow-run-store-contract.js +13 -0
- package/dist/core/workflow-run-store.d.ts +15 -0
- package/dist/core/workflow-run-store.js +1 -0
- package/dist/engine/loop/agent-loop.js +10 -24
- package/dist/tools/fs/bash-program-position.d.ts +23 -0
- package/dist/tools/fs/bash-program-position.js +11 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +30 -7
- package/dist/tools/fs/bash-readonly-classifier.js +31 -10
- package/dist/tools/fs/fs-bash.d.ts +30 -1
- package/dist/tools/fs/fs-bash.js +123 -54
- package/dist/tools/fs/read-deny.d.ts +14 -0
- package/dist/tools/fs/read-deny.js +5 -0
- package/dist/tools/fs/safety.d.ts +1 -1
- package/dist/tools/fs/safety.js +2 -1
- package/dist/tools/monitor.d.ts +5 -0
- package/dist/tools/monitor.js +7 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
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 {
|
package/dist/brain/anthropic.js
CHANGED
|
@@ -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 {
|
|
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
|
|
146
|
-
const code = e
|
|
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",
|
package/dist/brain/degrading.js
CHANGED
|
@@ -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);
|
package/dist/brain/errors.d.ts
CHANGED
|
@@ -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
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
/**
|
|
133
|
-
*
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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 {};
|
package/dist/brain/errors.js
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
109
|
+
if (!isErrorLike(node))
|
|
114
110
|
return verdict;
|
|
115
|
-
const members = node
|
|
116
|
-
|
|
117
|
-
|
|
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 (
|
|
120
|
-
if (walkConnectFailure(
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
package/dist/brain/failover.js
CHANGED
|
@@ -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");
|
package/dist/brain/openai.js
CHANGED
|
@@ -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"
|
|
31
|
-
*
|
|
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;
|