@sema-agent/core 7.24.1 → 7.24.3
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 +62 -0
- package/dist/agents/subagent.js +4 -1
- package/dist/brain/anthropic.js +2 -2
- package/dist/brain/errors.d.ts +6 -4
- package/dist/brain/open-responses.js +2 -2
- package/dist/brain/openai.js +2 -2
- package/dist/brain/route-adjudicator.d.ts +41 -13
- package/dist/brain/route-adjudicator.js +97 -18
- package/dist/core/checkpoint-store.d.ts +127 -102
- package/dist/core/checkpoint-store.js +33 -9
- package/dist/core/mcp-failure.d.ts +73 -0
- package/dist/core/mcp-failure.js +21 -0
- package/dist/core/mcp.d.ts +18 -1
- package/dist/core/mcp.js +33 -12
- package/dist/core/memory-engine/distiller.js +3 -3
- package/dist/core/runner/prepare-boundary-parks.js +3 -3
- package/dist/core/runner/prepare-park-ask.js +2 -2
- package/dist/core/runner/prepare-wiring-manifest.d.ts +3 -2
- package/dist/core/runner/prepare-wiring-manifest.js +3 -1
- package/dist/core/runner/resume-checkpoint-screen.js +11 -10
- package/dist/core/runner/resume-policy-outcome.js +2 -2
- package/dist/core/wiring-manifest.d.ts +20 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/prompt-assembly/packs/sema-default.js +15 -0
- package/dist/prompts/default.d.ts +1 -1
- package/dist/prompts/default.js +4 -4
- package/dist/tools/fs/bash-program-position.d.ts +5 -0
- package/dist/tools/fs/bash-program-position.js +1 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +476 -1
- package/dist/tools/fs/bash-readonly-classifier.js +98 -34
- package/dist/tools/fs/fs-bash.js +2 -325
- package/dist/tools/fs/shell-read-establish.d.ts +127 -0
- package/dist/tools/fs/shell-read-establish.js +326 -0
- package/dist/tools/fs/shell-read-plan.d.ts +113 -0
- package/dist/tools/fs/shell-read-plan.js +700 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +9 -1
- package/wire-changes/core@7.24.1.json +3 -17
- package/wire-changes/core@7.24.2.json +81 -0
- package/wire-changes/core@7.24.3.json +244 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 7.24.3 — 2026-09-20
|
|
6
|
+
|
|
7
|
+
### A per-session value high in the prompt costs the whole shared prefix (#975; P2; prompt SECTION ORDER, no key and no wording change; @server @cli @client-core)
|
|
8
|
+
- **Before.** The session reminder-mark declaration extended the reminder sentence inline, ~290 characters into the system prompt. The value is per-session random, and a provider-side prefix cache (vLLM automatic prefix caching, Anthropic prompt caching) reuses the longest IDENTICAL token prefix — so a new session matched those ~290 characters and re-prefilled everything after them. Measured on a real deployment at 7.24.1: the same body replayed 120–306ms TTFB, a NEW session's first turn 440–500ms, the same session's second turn 216–290ms.
|
|
9
|
+
- **After.** The declaration is its own section, `core/harness.reminder-mark` (rank 590), emitted with the prompt's session-variable group: declaration → environment block → memory tail. The `# Harness` head went back to the mark-free sentence it carried before the mark existed. On the default assembly the first byte that differs between two sessions moved from index 290 to 9 406 of 10 280 (2.8% → 91.5%); the ratio is fixture-dependent (a large env tail lowers it) but the property is not — nothing of the shared base follows the declaration any more.
|
|
10
|
+
- **Nothing about the defense changed.** Same sentence, same wording, exactly one per composed prompt, and `<system-reminder mark="…">` comparison never read position to begin with. The section is LOCKED: a `replaceAll` persona does not displace it (it is mechanism truth, not identity), and the already-assembled pass-through still gets the existing "declaration missing" disclosure rather than a second copy. **Only this sentence moved** — cwd/date have ridden the environment tail since the pack existed.
|
|
11
|
+
- **No public composite emits the grant without its caveat.** The head GRANTS `<system-reminder>` tags authority and the declaration is what lets the model tell an engine mint from a forgery, so the two travel together: `harnessContext()` appends the declaration when a mark is threaded, and `constitutionBlocks()` / `composeConstitution()` / `defaultPromptProvider.stableSystem` inherit exactly one. The single deliberate exception is `harnessHeadLines()` — the PACK's section source, which returns the grant alone because the pack ranks the caveat itself; it no longer takes a `reminderMark` at all, so it cannot regrow one by accident. Two adversarial rounds pushed this boundary down to the lowest level it can sit at.
|
|
12
|
+
- **CC 2.1.259, checked.** CC has no per-session mark, so this seat is UNANCHORED. Its nearest relative, CC's `env_info_simple` block, sits MID-list in the default builder (ahead of several static sections) and near the tail in the subagent builder; sema keeps its environment tail strictly last and puts this beside it — a divergence that predates this change and is kept on purpose (CC caches behind explicit breakpoints, sema's deployments behind longest-identical-prefix caches).
|
|
13
|
+
- **Reach.** `prompt.assembled` gains one block id (`harness.reminder-mark`) and `harness.context` loses 886 characters to it; a prompt EPOCH re-pins once per session on upgrade, which is the ordinary declared-bytes-changed path. No wire key, no code, no posture. `test/backlog975-reminder-mark-prefix.test.ts` pins the two-session prefix property with a positive control (two distinct marks), the one-declaration/nothing-stable-after rule, the same-session byte-identity invariant #254 established, and both the task-`systemPrompt` and `replaceAll` shapes.
|
|
14
|
+
|
|
15
|
+
### An MCP server's reachability is its own fact, and absence of it is indeterminate (#874; P2; additive wire key + additive record field + 3 exports; @server @cli @client-core)
|
|
16
|
+
- **Before.** Three faces reported three different things about an MCP server and none of them answered "can the engine reach it right now": `wiring_manifest.mcp[].status` is the connect-time verdict of the dial, deliberately frozen (a server that dies mid-task still reads `connected`); `McpServerStatus.transportClosed` is one EVENT, and only on transports that raise one — a streamable-HTTP endpoint that dies never does; `McpReconnectResult.outcome` is one action's judgment. A shell asking "is this server up" had to infer it, and every inference folded an unknown into a health verdict.
|
|
17
|
+
- **After.** `wiring_manifest.mcp[].liveness` (and the same record on `McpServerStatus.liveness`) carries `{state, observedAt, errorCode?}` — the engine's last OBSERVATION, stamped where the connection layer already works: the dial, a re-dial, a transport close, and a request that comes back with the transport gone (the one case `transportClosed` structurally cannot see). **No probe, no poll, no timer**: core adds no traffic of its own, so the manifest's copy is a per-leg snapshot and nothing pretends otherwise.
|
|
18
|
+
- **Three words, one question — "did an exchange complete".** `reachable` includes a server that ANSWERED the handshake with a protocol error, so `status:"failed"` beside `liveness.state:"reachable"` is a real row and a useful one (up, and misconfigured). `unreachable` = refused / reset / closed / unspawnable / timed out. `unknown` = something came back and it does not say (an HTTP status a gateway in front of the server may have written, a non-MCP payload, a thrown value with no structure). The mapping is ONE table over the existing `MCP_FAILURE_KINDS` (`LIVENESS_BY_FAILURE_KIND`, fenced like `DELIVERED_BY_KIND`) — no new code vocabulary, and a new failure class cannot land without saying what it means for reach.
|
|
19
|
+
- **Absence is the fourth reading and is not a word.** The key is present IFF an observation exists: a `skipped` row never reached a transport, a malformed declaration (`invalid_config`) was never dialed, and an older manifest has no field. A consumer must not fold absence into `unknown` — `unknown` is the stronger statement that core DID look and could not tell.
|
|
20
|
+
- **`errorCode` inside the record rides `unreachable` only**, deliberately: on `unknown` the failure class was judged NOT to decide reachability, and carrying it there would invite a reach action keyed on evidence that says nothing about reach. The connect-time class is still reported in its own right as the row's top-level `errorCode`.
|
|
21
|
+
- **Every exchange reports, at the REQUEST boundary; the record is the LAST observation, not the worst one.** A dial, a tool call, a resource read, each listing page, a refresh's failed listing and the idle watchdog's own expiry report their outcome through the same writer — including SUCCESS, so a demotion can never outlive the next exchange that works. Deliberately NOT after a paginating helper returns: those recover from later-page failures and can return having issued no request at all, so a stamp there would fabricate a recovery (`KL-874a` names the three paths that therefore take no observation, and what a stale record costs). The first cut filtered the request lane to `connection_closed` and an adversarial round showed why that is the wrong half: a streamable-HTTP endpoint that dies raises no close notification, and its next request fails `connect_refused` / `connection_failed`. There is no per-site judgment about which failures "count" — the class table decides, and a class that decides nothing about reach writes nothing.
|
|
22
|
+
- **The engine's own teardown records nothing.** All observations go through ONE lifecycle-aware writer on the per-server health object (the slot discipline `announce.fn` already used): it is silent while the task is tearing down — our own `dispose()` closes every transport, and stamping "this server died" on the way out is a lie a consumer reading the statuses afterwards cannot tell from the truth — and it is DISARMED when a re-dial deliberately replaces a handle. An adversarial round found the first cut recording a teardown as an outage; the fix removed a write site rather than adding a guard.
|
|
23
|
+
- **Reach.** Additive: one optional wire key, one optional field on an existing public record, three new exports (`MCP_LIVENESS_STATES`, `McpLivenessState`, `McpServerLiveness`). No key removed, re-typed or re-meant; `status`, `transportClosed` and the re-dial result are byte-unchanged. `@client-core` asked for this face (CC-36); `@server` relays the manifest; `@cli` renders it. `test/backlog874-mcp-liveness.test.ts` pins the three states and the two absence forms on REAL `wiring_manifest` frames (a live stdio server, a missing binary, a real HTTP endpoint answering 401, a malformed url), the re-dial flip, and the `errorCode`-only-on-`unreachable` law.
|
|
24
|
+
|
|
25
|
+
### One extraction of shell read candidates, ahead of one judge (#945 S0; zero behaviour change; no wire key; no export)
|
|
26
|
+
- **What lands.** `src/tools/fs/shell-read-plan.ts` — `planShellReads(command, decl)`, a pure, zero-I/O parse of a shell command into every path word it MAY read, each tagged with its provenance (`READ_CANDIDATE_PROVENANCES`, a closed set of ten words registered in `docs/CLOSED-SETS.md`) and the working-directory candidates it is relative to; nested plans for substitution bodies, unquoted here-document substitutions and re-entered shell text (`sh -c`, `eval`, `env -S`, a launcher handing text to a shell); `<` targets as read candidates and `>`-family targets as tagged write targets; `cd` threaded as a base-candidate set (`&&` proves a move), a loop body replayed per literal beat. Nothing consumes it yet: the three shell faces judge exactly as before.
|
|
27
|
+
- **What moves.** The filesystem-resolving read boundary (`createCanonicalReadBoundary`, its glob matcher and bounds, `BoundaryQuestions`) leaves `fs-bash.ts` for `src/tools/fs/shell-read-establish.ts`, byte-for-byte; `fs-bash.ts` imports it back. `dist/` shows the move; no call site changed.
|
|
28
|
+
- **Two readers now say more, to the same callers.** `readSedScript` returns the file names its `r`/`R` (read) and `w`/`W` (write) commands name and the command lines its `e` commands run, where it used to answer only "names"; every existing caller reads the same kind it always did (pinned). `LauncherRow` gains a `commandString` arity (`env`'s `-S` / `--split-string`); the program-position reader still refuses that spelling exactly as before (pinned) — only the candidate extraction reads the value.
|
|
29
|
+
- **The proof of "zero behaviour change" is a 219-cell matrix in tree** (`test/backlog945-matrix.test.ts`): three shell faces × two moments over the design's 38 shapes, the no-deny controls, and four `#976` shapes, every cell a reading taken on the pre-slice base with the same harness. LEAK cells are recorded as leaks; the pipeline slice flips them one marked commit at a time.
|
|
30
|
+
- **Two operand readers are exported from `bash-readonly-classifier.ts`** for the plan (`readVerbOptionSlot`, `slotValueCandidates`, `attachedOptionPayloads`, `segmentSelectsRecursiveRead`, `inlineProgramClause`, …): internal modules, not on the package's export surface (`handoff:diff` v7.24.2 → HEAD: +0 −0).
|
|
31
|
+
- Reach: @server / @cli / @client-core zero; headless zero.
|
|
32
|
+
|
|
33
|
+
## 7.24.2 — 2026-09-20
|
|
34
|
+
|
|
35
|
+
### A query whose separators are not the deployment's has no names to speak (#974; P1; security-facing narrowing of a rendered string; no wire key; @server @cli @client-core)
|
|
36
|
+
- **Before.** `displayUrlOf` withholds every query VALUE and keeps every NAME — names ride so that a refusal about two roots differing only in their query cannot read "targets X … paired with X". But `&` and `=` are the query's separators only while no VALUE carries its own. A value holding an un-encoded URL owns them: the split lands where that value says, and whatever ends up left of an `=` was printed as a name. `?next=https://u:&pw@h=` printed `pw`; `?a=1&https://u:sekret@h/=x` printed the whole embedded URL, password included.
|
|
37
|
+
- **Two fixes were tried and measured away before this one.** Requiring names to be identifier-SHAPED fails on three spellings whose tails are perfect identifiers (`…u:&SeCrEt123=x@h`, and its `-`/`_` variants). Testing the raw query for the markers `://` and `@` is a PROXY, and an adversarial round produced `?next=https%3A%2F%2Fu%3A&SeCrEt123=x%40h` — neither marker present literally, `SeCrEt123` printed — with double encoding behind it.
|
|
38
|
+
- **After — purity, asked directly.** A query is PURE iff **every** `name=value` pair, **decoded to its fixed point** (≤3 rounds; a malformed escape stops the decode and leaves its `%` in place), is plain text: `[A-Za-z0-9._~[\]-+,*!'()$ ]`, with a non-empty name. One impure pair and the whole query renders as a single `?«redacted»` with no names at all, and the refusal's parenthetical (`queryDifferenceNote`, the SECOND exit — it used to list what the split left in the name position) reports **positions** instead: "the query differs at segments 2, 3 of 3", or a count comparison when the lengths differ (SEGMENTS, because the count is of `&`-separated segments — a bare token and a trailing separator are segments and not parameters). A segment with no `=` renders as before — a nameless placeholder — but it is JUDGED: a third adversarial round showed that exempting it leaves the hole open (`?next=user&SeCrEt123=x&y@example.com` — the `@` lands in the nameless segment, the second segment then reads as a plain pair whose NAME is the credential, measured). A bare token cannot name anything; it can prove the split was not the deployment's.
|
|
39
|
+
- **Why purity rather than a marker.** The proxy asks "does this look like it contains a URL"; purity asks the question the split actually depends on, at every encoding depth, and needs no list of the shapes structure can take. The character set is generous where a real deployment lives (`?q=hello world` from `%20` or `+`, `?sort=-created_at,id`, `?filter[name]=x` and its `%5B` spelling) and strict exactly where a split goes wrong: where an embedded URL is what mis-split the query, its own prefix pair carries `:`/`/`/`@` and fails the test.
|
|
40
|
+
- **Version context, stated exactly.** 7.24.0 made the **userinfo** position strictly safer (the whole userinfo drops; 7.23.5 still rendered the username) and left the **query name** position looser for a value carrying an un-encoded URL. This release closes the EMBEDDED-URL class of that. It does not close — and cannot — a bare secret that carries its own `&` and `=` (`?password=hunter&SeCrEt=123` is byte-identical to two ordinary parameters): `KL-974b` records it with the deployment discipline (percent-encode a secret written into a query value) and a READ-form pin so nobody reads the purity law as covering it.
|
|
41
|
+
- **The cost.** A legitimate structured value now costs its query the names as well: `?redirect_uri=https://x/cb` and `?email=a@b.com` render as one placeholder at any encoding depth, and the operator gets the differing segment's POSITION instead of its name. `KL-974a` records the trade; `KL-950a`'s "a parameter's NAME is not withheld" item is rewritten to this rule; `KL-974c` accepts the position note's segment-equality disclosure; `KL-974d` records two parse shapes the servable gate deliberately does not second-guess; `KL-974e` records that the tree-wide guarantee covers `src/` only.
|
|
42
|
+
- **Reach.** `displayUrlOf` is exported and its output rides `RouteRefusalDetail.{entryBaseUrl, requestBaseUrl, configBaseUrl}`, `routeRefusalText`/`fixHint`, and the `route.base_url_changed_key_unchanged` notice. No key, code, posture or shape changes; one rendered string gets stricter, and the renderer never throws (a throwing renderer is one a caller wraps in a fallback, and the fallback is the leaking path — pinned). `test/backlog974-display-url-query-ambiguity.test.ts` pins both exits, the measured leak spellings including the partially- and doubly-encoded ones, the decode bound and malformed escapes, the position/count note in both forms, and the plain-text positive controls a downstream consumer named.
|
|
43
|
+
|
|
44
|
+
### A lost response is not a logic error (#964; P2; wire VALUE flip on `extras.error.retryable`; @cli @client-core)
|
|
45
|
+
- **Before.** `SUBAGENT_ERROR_KIND_BY_CODE` carried rows for the transport codes and nothing for the three IN-BAND terminal-output codes, so `stream_torn` / `refusal` / `length_empty` fell through to `logic` together with `auth`, `output.*` and every code the table does not recognize. That verdict does not stay inside the engine: it rides the delegation card as `extras.error.error_kind` / `extras.error.retryable`, which is what a host renderer shows and a retry scheduler reads. A child whose response was **dropped in transit** — a stream that ended with no content and no finish/stop reason — told its parent "re-delegating cannot fix this".
|
|
46
|
+
- **After.** Each of the three has a row of its own. `stream_torn` → the transient `network` class (`retryable: true`): a lost response is the textbook re-issuable failure. `refusal` (the provider's policy plane withheld the output) and `length_empty` (the output cap was spent before any answer text) → `logic` (`retryable: false`) — the same verdict the fall-through produced, with the difference that it is now DECIDED: a code with a row is classified, a code without one is merely unrecognized.
|
|
47
|
+
- **The fix is in the TABLE, not at a seat.** A special case at one reader would have left the same defect for the next code, and there are three readers (the delivery card, the background notification faces, the team seat). Rules: +3 rows, −1 class of silent mis-verdicts; no reader changed.
|
|
48
|
+
- **Reach — a value flip on an existing key.** `extras.error.{error_kind, retryable}` are unchanged in shape and presence; for a `stream_torn` child they now read `network` / `true` instead of `logic` / `false`. `@cli` / `@client-core` render that pair — a "not retryable" badge on a dropped stream becomes "retryable". `@server` has no read point (it forwards the card), so nothing to change there. `team.ts`'s `canDifferOnRetry` is deliberately **unchanged**: it stops re-dispatch only for terminals judged deterministic (`blocked`, and a code the governance table adjudicated terminal), and reading `retryable` directly there would hang "never retry" on the classifier's else-arms, which is the defect #941 exists to prevent. `test/backlog964-subagent-inband-error-class.test.ts` pins the card through a real child run (report text and `details`), the untouched governance/else-arm readings, and the #941 narrowing.
|
|
49
|
+
- **One hardening from the adversarial round.** The code→class lookup read the table with a plain index, so a terminal code spelled `constructor` or `toString` (a terminal code is a string from a host runner's own taxonomy, not only from the brains' closed prefix list) resolved a PROTOTYPE member and classified the child as a function value with `retryable: undefined` on its card. It now asks own-property membership, the same reading the governance table has always used.
|
|
50
|
+
- `docs/KNOWN-LIMITS.md` KL-942d records the defect and its fix rather than being deleted.
|
|
51
|
+
|
|
52
|
+
### Joining an endpoint onto a request root is a URL operation, not a string one (#961; P2; narrowing: a base URL may no longer carry a fragment; additive export `joinRequestUrl`; @server @cli @client-core)
|
|
53
|
+
- **Before.** Four builders composed `${root}${suffix}` by hand — the three first-party brains (`/chat/completions`, `/v1/messages`, `/responses`) and the memory-distillation chat seat. For a root with no query that is exactly what a URL join produces, which is why it survived. For the root a gateway deployment really declares — `https://h/v1?api-version=2024-06-01`, the Azure shape — the suffix landed **inside the last query value** (`api-version=2024-06-01/chat/completions`) and the request went to `/v1`. For a root carrying a fragment it was worse: `fetch` never sends one, so the whole suffix landed in the discarded part and every call silently hit the root's own path. `assertServableRequestRoot` accepted both spellings and returned the raw text, which is how they reached the builders (registered under 7.24.0 as "not fixed here").
|
|
54
|
+
- **After.** One `joinRequestUrl(root, suffix)` beside the rest of the request-root law: the suffix joins the **path** with exactly one separator, the **query** rides byte-preserved after it (a deployment declares it once for every request), and a **fragment** is refused rather than composed. The refusal is where the value arrives — `assertServableRequestRoot`, so it fires at brain construction for `config.baseUrl`, at request build for `Model.baseUrl`, and at the distillation seat's construction — and it is re-asked inside the join, so a root that reached a builder by some other path cannot compose a wrong URL in silence. The refusal **names the part and withholds its content**: implicit-OAuth puts a bearer token in exactly that position, and the display spelling drops fragments outright, so the sentence spells `#…` instead of rendering it.
|
|
55
|
+
- **The key's fold followed the wire, as it must.** `routeKeyOf` now also folds the trailing slashes of the **path**: once a suffix joins the path, `https://h/v1/?tenant=a` and `https://h/v1?tenant=a` are byte-identical requests, and a key that still called them two endpoints would refuse a correctly paired credential over a difference the wire cannot see. The invariant **two roots share a key iff their requests are the same request** is re-pinned as a table against the real composition (`test/route-adjudication.test.ts`), and its one registered exception — a root CARRYING a fragment — is **closed**: there is no longer a pair to disagree about, because the root is refused before any request is composed. The key still folds fragments, which now reaches only the advisory read face (it judges a root without asking whether that root is servable at all, exactly as it tolerates an unparseable spelling).
|
|
56
|
+
- **One fold rule, after an adversarial round found two.** `resolveRequestRoot` stripped trailing slashes off the SERIALIZED root and `routeKeyOf` did the same before parsing — which eats the last byte of a query VALUE. Measured: a deployment declaring `https://h/v1?tenant=a/` sent `tenant=a`, while a caller using the exported `joinRequestUrl` directly sent `tenant=a/`; and the key equated the two, so a deployment credential could be judged `paired` with a root that targets a different endpoint. The fold now lives in one place (`canonicalRootSpelling`: trailing slashes belong to the PATH, the query rides byte-for-byte) and the resolver, the key and the join all answer with it. Rules: three normalizations become one.
|
|
57
|
+
- **A suffix that leaves the root is refused, and it is VERIFIED rather than screened.** The exported join re-reads the composed URL with the same parser `fetch` uses and requires the result to still be under the root's path: `joinRequestUrl("https://h/a", "/../responses")` and its `%2e%2e` spelling both normalize to `https://h/responses` and are now `TypeError`s. A blacklist would have had to enumerate `..`, `%2e%2e`, a backslash and whatever the next revision of the URL standard folds; the re-read asks the only question that matters — does the request still target the root the pairing law verified a credential against. The three brains pass fixed literals and are unaffected; the check exists because the function is exported and a custom brain's suffix is not fixed. Positive controls (an ENCODED slash, a single dot, a doubled separator) still compose.
|
|
58
|
+
- **Reach — narrowing.** A deployment whose `config.baseUrl` / `Model.baseUrl` carries a `#fragment` now fails loudly at construction or request build instead of silently calling the wrong path; there is no compensating knob, because every such call was already going to the wrong place. A deployment whose root carries a **query** keeps working and now actually reaches its endpoint. Additive export `joinRequestUrl` (+1, tier advanced); no wire frame key; no change to the pairing verdicts, postures or refusal codes. `test/backlog961-request-url-join.test.ts` pins the four builders on the captured request URL of a real `fetchImpl` (not a helper's return value), the two refusal seats, the "no site concatenates any more" structural half (five files — the custom-brain EXAMPLE is a site too, and it cannot teach a law it composes around), and the join's corners (trailing slashes, empty query, `%2F`, IPv6 authority).
|
|
59
|
+
|
|
60
|
+
### The checkpoint version ladder is one declaration table (#959; P2; no wire key; no export change)
|
|
61
|
+
- **Before.** "How high a version does this row need" was answered by three hand-written ternary ladders (the resource park, the plan-review park, the approval park — 13 conditional nodes over eight per-feature constants), each a copy of the precedence between features, and only the approval ladder had the real-approval arm because only there is the bit in scope. Adding a feature meant a new constant, a new arm in every ladder, a hand-raised ceiling, and a resume rung comparing against the new constant — the same precedent replayed seven times, with v4 burnt along the way.
|
|
62
|
+
- **After.** `CHECKPOINT_FEATURES` is the one table: a row per feature that ever raised the format, carrying its version, its READ-face mark (`recordedOn`: the persisted member whose presence says the feature was written on a row) and its WRITE-face obligation (`writes`) as two separate columns, plus where it came from. Minting is `checkpointVersionFor(presence)` — the highest version among the features a site declares present; the presence record has a MANDATORY key per non-baseline row, so a new row is a compile error at each of the three mint sites until answered. The eight exported constants are unchanged in name and value and now read the rows (`= CHECKPOINT_FEATURES.<row>.version`); the ceiling is the top row (a tripwire pins `max(rows) === MAX_SUPPORTED_CHECKPOINT_VERSION`). The resume screen's version-keyed rungs compare against the rows and probe presence through `row.recordedOn`; the four rule bodies and their order stay in the screen (two rule families — sectioned features with a forgery arm, declared features without one — and the F-012 rung's seat after the re-supply checks are properties of the screen, not of a row). v4 is a deliberate gap in the table: the retired axis is refused by FIELD at any version, as before.
|
|
63
|
+
- **Reach.** Zero behavior change: every stamped number and every resume verdict is what the ladders produced (`test/backlog959-checkpoint-feature-table.test.ts` keeps the three ladders and the version-keyed rungs verbatim as its reference — 64 presence combinations for the mint, a 27 000-row version × payload matrix through the real policy lane and checkpoint screen for the resume). No export added or removed; the table and `checkpointVersionFor` are module exports (no downstream consumer needs them). `CheckpointFeature` is registered as a `keys:` closed set. Rules: 13 ladder nodes → 0; the feature precedence spelled three times → the version numbers themselves; the hand-raised ceiling and baseline → derived. Recorded for the next feature: above v9 the read-face section is no longer implied by the stamp, so v10 must carry a mark of its own (or the row a per-feature presence record) — the law is in the table's doc.
|
|
64
|
+
|
|
3
65
|
## 7.24.1 — 2026-09-19
|
|
4
66
|
|
|
5
67
|
### The model-role vocabulary ships its runtime twin (#965; P3; additive export `MODEL_ROLES`; @server @client-core)
|
package/dist/agents/subagent.js
CHANGED
|
@@ -362,6 +362,9 @@ const SUBAGENT_ERROR_KIND_BY_CODE = {
|
|
|
362
362
|
server: "network",
|
|
363
363
|
network: "network",
|
|
364
364
|
"limits.max_walltime_exceeded": "timeout",
|
|
365
|
+
stream_torn: "network",
|
|
366
|
+
refusal: "logic",
|
|
367
|
+
length_empty: "logic",
|
|
365
368
|
};
|
|
366
369
|
const SUBAGENT_RETRYABLE = {
|
|
367
370
|
rate_limit: true,
|
|
@@ -377,7 +380,7 @@ export function classifySubagentError(terminal) {
|
|
|
377
380
|
const code = terminal.code;
|
|
378
381
|
if (code === undefined)
|
|
379
382
|
return { errorKind: "unknown", retryable: SUBAGENT_RETRYABLE.unknown };
|
|
380
|
-
const own = SUBAGENT_ERROR_KIND_BY_CODE[code];
|
|
383
|
+
const own = Object.prototype.hasOwnProperty.call(SUBAGENT_ERROR_KIND_BY_CODE, code) ? SUBAGENT_ERROR_KIND_BY_CODE[code] : undefined;
|
|
381
384
|
if (own !== undefined)
|
|
382
385
|
return { errorKind: own, retryable: SUBAGENT_RETRYABLE[own] };
|
|
383
386
|
const governanceClass = governanceRetryClass(code);
|
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, assertServableRequestRoot, createBrainRouteJudge, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
|
|
10
|
+
import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, joinRequestUrl, 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) {
|
|
@@ -347,7 +347,7 @@ export function createAnthropicBrain(config = {}) {
|
|
|
347
347
|
: "config-default";
|
|
348
348
|
const wire = applyExtraBody(body, model.extraBody, ANTHROPIC_RESERVED);
|
|
349
349
|
return {
|
|
350
|
-
url:
|
|
350
|
+
url: joinRequestUrl(root, "/v1/messages"),
|
|
351
351
|
headers,
|
|
352
352
|
body: JSON.stringify(wire),
|
|
353
353
|
outputCapTokens: effectiveOutputCap(wire, OUTPUT_CAP_KEYS.anthropic),
|
package/dist/brain/errors.d.ts
CHANGED
|
@@ -14,10 +14,12 @@ export declare const ZERO_USAGE: Usage;
|
|
|
14
14
|
*
|
|
15
15
|
* The last three are IN-BAND terminal-output codes: stamped on errored assistant MESSAGES
|
|
16
16
|
* (never thrown as BrainError) so `TaskResult.errorCode` covers the error family end-to-end and
|
|
17
|
-
* downstream consumers can bucket without string-matching.
|
|
18
|
-
*
|
|
19
|
-
* outages)
|
|
20
|
-
*
|
|
17
|
+
* downstream consumers can bucket without string-matching. Behavior-neutral on the transport side:
|
|
18
|
+
* the circuit breaker's default countCodes excludes them (model-output problems, not provider
|
|
19
|
+
* outages) and degradation maps them to nothing. Subagent retry classification gives each of them a
|
|
20
|
+
* ROW of its own (`SUBAGENT_ERROR_KIND_BY_CODE`): a lost stream is the transient `network` class, a
|
|
21
|
+
* policy refusal and a spent output cap are `logic`. They rode the `logic` fall-through together
|
|
22
|
+
* until then, which told a parent "do not re-delegate" about a dropped response.
|
|
21
23
|
* - `stream_torn` — the stream ended with no content and no finish/stop reason (response lost)
|
|
22
24
|
* - `refusal` — policy refusal / provider content filter withheld the output
|
|
23
25
|
* - `length_empty` — max_tokens exhausted with no answer text (pairs with errorKind "length_empty") */
|
|
@@ -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, assertServableRequestRoot, createBrainRouteJudge, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
|
|
9
|
+
import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, joinRequestUrl, 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;
|
|
@@ -286,7 +286,7 @@ export function createOpenResponsesBrain(config = {}) {
|
|
|
286
286
|
headers["authorization"] = `Bearer ${apiKey}`;
|
|
287
287
|
const wire = applyExtraBody(body, model.extraBody, RESPONSES_RESERVED);
|
|
288
288
|
return {
|
|
289
|
-
url:
|
|
289
|
+
url: joinRequestUrl(root, "/responses"),
|
|
290
290
|
headers,
|
|
291
291
|
body: JSON.stringify(wire),
|
|
292
292
|
outputCapTokens: effectiveOutputCap(wire, OUTPUT_CAP_KEYS.responses),
|
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, assertServableRequestRoot, createBrainRouteJudge, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
|
|
10
|
+
import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, joinRequestUrl, 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) {
|
|
@@ -346,7 +346,7 @@ export function createOpenAIBrain(config = {}) {
|
|
|
346
346
|
headers["authorization"] = `Bearer ${apiKey}`;
|
|
347
347
|
const wire = applyExtraBody(body, model.extraBody, OPENAI_RESERVED);
|
|
348
348
|
return {
|
|
349
|
-
url:
|
|
349
|
+
url: joinRequestUrl(root, "/chat/completions"),
|
|
350
350
|
headers,
|
|
351
351
|
body: JSON.stringify(wire),
|
|
352
352
|
outputCapTokens: effectiveOutputCap(wire, OUTPUT_CAP_KEYS.openai),
|
|
@@ -42,9 +42,10 @@ import type { RouteAdjudication, RouteCredential, RoutePairingConfig, RoutePairi
|
|
|
42
42
|
import type { Brain, EngineNotice } from "../core/types.js";
|
|
43
43
|
/** A base URL's ROUTE KEY — the value the pairing law COMPARES, folded to WIRE equivalence: the
|
|
44
44
|
* halves the wire itself treats as case-insensitive (scheme and host, RFC 9110) fold, the scheme's
|
|
45
|
-
* default port elides, and PATH and QUERY ride byte-preserved
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* default port elides, and PATH and QUERY ride byte-preserved apart from the trailing slashes the
|
|
46
|
+
* request composition itself folds (a query selects the endpoint as surely as a path does; folding
|
|
47
|
+
* anything the wire keeps would compare two different endpoints equal — the widening this module
|
|
48
|
+
* exists to stop). An unparseable value keeps its raw trimmed spelling: fail-closed raw
|
|
48
49
|
* comparison. Non-string input throws — see the module note on garbage.
|
|
49
50
|
*
|
|
50
51
|
* **Never render this.** Byte-preserving on the query means `?api_key=<secret>` rides it verbatim;
|
|
@@ -61,29 +62,56 @@ export declare function routeKeyOf(url: string | undefined): string;
|
|
|
61
62
|
* - a BARE token (`?<token>`) renders as one placeholder with NO name: `URLSearchParams` calls it a
|
|
62
63
|
* name with an empty value, and that reading put the payload itself on screen. "Every value is
|
|
63
64
|
* withheld" protects only what IS a value, so the raw segment decides what rides.
|
|
65
|
+
* - a name rides only where {@link readQuery} finds the query PURE. A query holding any structured
|
|
66
|
+
* value — a URL, an e-mail address, anything that is not plain text once decoded — owns the `&`
|
|
67
|
+
* and `=` the split relies on, so the bytes left of an `=` are not names and are never spoken:
|
|
68
|
+
* such a query renders as ONE placeholder. A deployment's ordinary names (`tenant`, `api-version`,
|
|
69
|
+
* `x-request-id`, `filter[name]`, `x.y`) are unaffected, which is what keeps a refusal actionable.
|
|
64
70
|
* Answers `""` for an undeclared root, like its key twin. */
|
|
65
71
|
export declare function displayUrlOf(url: string | undefined): string;
|
|
66
72
|
/**
|
|
67
|
-
* The bad-value refusal every base-URL seat owes: **a request root must be an absolute http(s) URL,
|
|
68
|
-
*
|
|
73
|
+
* The bad-value refusal every base-URL seat owes: **a request root must be an absolute http(s) URL, it
|
|
74
|
+
* may not carry the credential itself, and it may not carry a fragment.**
|
|
69
75
|
*
|
|
70
|
-
*
|
|
76
|
+
* The first two fail the same way if they are not caught here — as a TRANSPORT failure, indistinguishable
|
|
71
77
|
* from an outage, retried for the whole backoff ladder. Measured: `file:`/`ftp:` reject with
|
|
72
78
|
* `TypeError("fetch failed")` over a causeless "unknown scheme", the exact shell a refused connection uses.
|
|
73
79
|
* Stripping userinfo instead of refusing would send an UNAUTHENTICATED request to the host the operator
|
|
74
|
-
* thought they had authenticated to.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
80
|
+
* thought they had authenticated to. A FRAGMENT fails worse than loudly: `fetch` never sends one, so a root
|
|
81
|
+
* carrying it silently targets the root's own path and every endpoint suffix appended after it lands inside
|
|
82
|
+
* the discarded part (`https://h/v1#note` + `/chat/completions` → a request to `/v1`). Refused where the
|
|
83
|
+
* value ARRIVES — construction for `config.baseUrl`, request build for `Model.baseUrl` — and again where a
|
|
84
|
+
* request URL is composed ({@link joinRequestUrl}), so a root that reached a builder by some other path
|
|
85
|
+
* cannot compose a wrong URL in silence. An undeclared root is not this rule's question; the PAIRING law
|
|
86
|
+
* keeps its tolerance for an unparseable spelling ({@link routeKeyOf}). Answers the value it judged.
|
|
77
87
|
*/
|
|
78
88
|
export declare function assertServableRequestRoot(url: string | undefined, seat: string): string | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* The request URL itself: a request ROOT joined with the endpoint SUFFIX its lane speaks. The one
|
|
91
|
+
* composition — `${root}${suffix}` put the suffix wherever the root's last character happened to leave it,
|
|
92
|
+
* which for a root carrying a query (`https://h/v1?api-version=2024-06-01`, the gateway shape a deployment
|
|
93
|
+
* really declares) appended it INSIDE the last query VALUE and sent the request to `/v1`.
|
|
94
|
+
*
|
|
95
|
+
* The rule, stated once for all four builders: the suffix joins the **path** (exactly one separator,
|
|
96
|
+
* whatever trailing slashes the root carries); the **query** rides byte-preserved AFTER it, because a query
|
|
97
|
+
* selects the endpoint as surely as a path does and a deployment declares it once for every request; a
|
|
98
|
+
* **fragment** never reaches here ({@link assertServableRequestRoot} refuses the root, re-asked here so a
|
|
99
|
+
* root that arrived by some other path is refused rather than composed).
|
|
100
|
+
*
|
|
101
|
+
* Composed from the same parse `fetch` itself would make, so the spelling carries no normalization the wire
|
|
102
|
+
* would not have done anyway. A suffix carrying its own `?`/`#` is a programmer error, not an input: the
|
|
103
|
+
* query belongs to the root, and the suffix names a path.
|
|
104
|
+
*/
|
|
105
|
+
export declare function joinRequestUrl(root: string | undefined, suffix: string): string;
|
|
79
106
|
/** Remove the userinfo from every authority in free text, WHOLE — the general redactor keeps a short or
|
|
80
107
|
* unprefixed username, and `TOKEN@host` / `TOKEN:x-oauth-basic@host` are the canonical PAT spellings. */
|
|
81
108
|
export declare function stripUrlCredentials(text: string): string;
|
|
82
109
|
/** The request ROOT a brain targets — `Model.baseUrl` else `config.baseUrl` else the lane's default,
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* has
|
|
110
|
+
* in its canonical spelling ({@link canonicalRootSpelling}: trailing PATH slashes folded, query
|
|
111
|
+
* byte-preserved) — with {@link assertServableRequestRoot} applied to both declared halves on the way.
|
|
112
|
+
* The three brains each carried a copy of this coalesce-and-strip line, so a rule about base URLs had
|
|
113
|
+
* three homes; now it has one. Answers `""` when nothing is declared and the lane has no default —
|
|
114
|
+
* whether that is a refusal is the caller's. */
|
|
87
115
|
export declare function resolveRequestRoot(modelBaseUrl: string | undefined, configBaseUrl: string | undefined, laneDefault?: string): string;
|
|
88
116
|
/** Whether a header bag carries an auth-bearing header (any case). */
|
|
89
117
|
export declare function hasAuthCarrier(headers: Record<string, string> | undefined): boolean;
|
|
@@ -28,21 +28,51 @@ export function routeKeyOf(url) {
|
|
|
28
28
|
if (typeof url !== "string") {
|
|
29
29
|
throw new TypeError(`route pairing: baseUrl must be a string (got ${typeof url})`);
|
|
30
30
|
}
|
|
31
|
-
const raw = url.trim()
|
|
31
|
+
const raw = url.trim();
|
|
32
32
|
if (raw === "")
|
|
33
33
|
return "";
|
|
34
34
|
const u = servableParse(raw);
|
|
35
35
|
if (u === undefined)
|
|
36
|
-
return raw;
|
|
37
|
-
return
|
|
36
|
+
return raw.replace(/\/+$/, "");
|
|
37
|
+
return canonicalRootSpelling(u);
|
|
38
38
|
}
|
|
39
|
-
function
|
|
39
|
+
function canonicalRootSpelling(u) {
|
|
40
|
+
return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}${u.search}`;
|
|
41
|
+
}
|
|
42
|
+
const QUERY_PURE_TEXT = /^[A-Za-z0-9._~[\]\-+,*!'()$ ]*$/;
|
|
43
|
+
const QUERY_DECODE_ROUNDS = 3;
|
|
44
|
+
function decodedToFixedPoint(text) {
|
|
45
|
+
let cur = text;
|
|
46
|
+
for (let round = 0; round < QUERY_DECODE_ROUNDS; round++) {
|
|
47
|
+
if (!cur.includes("%"))
|
|
48
|
+
return cur;
|
|
49
|
+
let next;
|
|
50
|
+
try {
|
|
51
|
+
next = decodeURIComponent(cur);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return cur;
|
|
55
|
+
}
|
|
56
|
+
if (next === cur)
|
|
57
|
+
return cur;
|
|
58
|
+
cur = next;
|
|
59
|
+
}
|
|
60
|
+
return cur;
|
|
61
|
+
}
|
|
62
|
+
function readQuery(search) {
|
|
40
63
|
if (search === "" || search === "?")
|
|
41
|
-
return [];
|
|
42
|
-
|
|
64
|
+
return { pure: true, segments: [] };
|
|
65
|
+
const segments = search.replace(/^\?/, "").split("&").map((raw) => {
|
|
43
66
|
const eq = raw.indexOf("=");
|
|
44
67
|
return eq === -1 ? { raw } : { raw, name: raw.slice(0, eq), value: raw.slice(eq + 1) };
|
|
45
68
|
});
|
|
69
|
+
const pure = segments.every((sg) => {
|
|
70
|
+
if (sg.name === undefined)
|
|
71
|
+
return QUERY_PURE_TEXT.test(decodedToFixedPoint(sg.raw));
|
|
72
|
+
const name = decodedToFixedPoint(sg.name);
|
|
73
|
+
return name !== "" && QUERY_PURE_TEXT.test(name) && QUERY_PURE_TEXT.test(decodedToFixedPoint(sg.value ?? ""));
|
|
74
|
+
});
|
|
75
|
+
return { pure, segments };
|
|
46
76
|
}
|
|
47
77
|
export function displayUrlOf(url) {
|
|
48
78
|
const key = routeKeyOf(url);
|
|
@@ -56,8 +86,12 @@ export function displayUrlOf(url) {
|
|
|
56
86
|
const shown = at === -1 ? head : `${REDACTED_VALUE}@${head.slice(at + 1)}`;
|
|
57
87
|
return cut === -1 ? shown : `${shown}${key[cut]}${REDACTED_VALUE}`;
|
|
58
88
|
}
|
|
59
|
-
const
|
|
60
|
-
const query =
|
|
89
|
+
const read = readQuery(u.search);
|
|
90
|
+
const query = read.segments.length === 0
|
|
91
|
+
? ""
|
|
92
|
+
: !read.pure
|
|
93
|
+
? `?${REDACTED_VALUE}`
|
|
94
|
+
: `?${read.segments.map((sg) => (sg.raw === "" ? "" : sg.name === undefined ? REDACTED_VALUE : `${sg.name}=${REDACTED_VALUE}`)).join("&")}`;
|
|
61
95
|
return `${u.protocol}//${u.host}${u.pathname}${query}`.replace(/\/+$/, "");
|
|
62
96
|
}
|
|
63
97
|
function queryDifferenceNote(requestKey, declaredKey) {
|
|
@@ -67,8 +101,19 @@ function queryDifferenceNote(requestKey, declaredKey) {
|
|
|
67
101
|
const ua = composableParse(requestKey);
|
|
68
102
|
const ub = composableParse(declaredKey);
|
|
69
103
|
if (ua !== undefined && ub !== undefined) {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
104
|
+
const ra = readQuery(ua.search);
|
|
105
|
+
const rb = readQuery(ub.search);
|
|
106
|
+
if (!ra.pure || !rb.pure) {
|
|
107
|
+
if (ra.segments.length !== rb.segments.length) {
|
|
108
|
+
return ` (same rendering — the query differs in segment count (${ra.segments.length} vs ${rb.segments.length}); a value here is not plain text, so this line withholds every name)`;
|
|
109
|
+
}
|
|
110
|
+
const at = ra.segments.flatMap((sg, i) => (sg.raw === rb.segments[i]?.raw ? [] : [i + 1]));
|
|
111
|
+
return at.length === 0
|
|
112
|
+
? ` (same rendering — the difference is in a part this line withholds or folds)`
|
|
113
|
+
: ` (same rendering — the query differs at segment${at.length > 1 ? "s" : ""} ${at.join(", ")} of ${ra.segments.length}; a value here is not plain text, so this line withholds every name)`;
|
|
114
|
+
}
|
|
115
|
+
const a = ra.segments;
|
|
116
|
+
const b = rb.segments;
|
|
72
117
|
const valuesOf = (segs, n) => segs.filter((sg) => sg.name === n).map((sg) => sg.value).join("\u0000");
|
|
73
118
|
for (const n of new Set([...a, ...b].flatMap((sg) => (sg.name === undefined ? [] : [sg.name])))) {
|
|
74
119
|
if (valuesOf(a, n) !== valuesOf(b, n))
|
|
@@ -80,6 +125,9 @@ function queryDifferenceNote(requestKey, declaredKey) {
|
|
|
80
125
|
: ` (same rendering — the difference is in a part this line withholds or folds)`;
|
|
81
126
|
}
|
|
82
127
|
export function assertServableRequestRoot(url, seat) {
|
|
128
|
+
return judgeRequestRoot(url, seat)?.trimmed;
|
|
129
|
+
}
|
|
130
|
+
function judgeRequestRoot(url, seat) {
|
|
83
131
|
if (typeof url !== "string" || url.trim() === "")
|
|
84
132
|
return undefined;
|
|
85
133
|
const trimmed = url.trim();
|
|
@@ -90,19 +138,50 @@ export function assertServableRequestRoot(url, seat) {
|
|
|
90
138
|
`or a spelling that is not a URL at all, fails at the transport in a shape indistinguishable from an ` +
|
|
91
139
|
`outage, so it would be retried for the whole backoff ladder instead of refused here.`);
|
|
92
140
|
}
|
|
93
|
-
if (parsed.username
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
141
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
142
|
+
throw new BrainError("invalid_request", `${seat} carries credentials in its authority (${parsed.protocol}//${REDACTED_CREDENTIALS}@${parsed.host}${parsed.pathname}) — ` +
|
|
143
|
+
`a request cannot be built from a URL with userinfo, so every call through this brain would fail: put the ` +
|
|
144
|
+
`credential on the apiKey knob or an auth header and leave the URL bare`);
|
|
145
|
+
}
|
|
146
|
+
if (parsed.hash !== "") {
|
|
147
|
+
throw new BrainError("invalid_request", `${seat} carries a URL fragment (${displayUrlOf(trimmed)}#…) — a fragment is never sent on the wire, so the ` +
|
|
148
|
+
`request would silently target the root's own path and the endpoint suffix would land inside the discarded ` +
|
|
149
|
+
`part: drop everything from the "#" on. A per-deployment QUERY is kept and rides every request ` +
|
|
150
|
+
`(a gateway's \`?api-version=…\` is supported); a fragment cannot be.`);
|
|
151
|
+
}
|
|
152
|
+
return { trimmed, parsed };
|
|
153
|
+
}
|
|
154
|
+
export function joinRequestUrl(root, suffix) {
|
|
155
|
+
if (typeof suffix !== "string" || /[?#]/.test(suffix)) {
|
|
156
|
+
throw new TypeError(`request URL: the endpoint suffix must be a path (got ${typeof suffix === "string" ? JSON.stringify(suffix) : typeof suffix}) — ` +
|
|
157
|
+
`a request's query belongs to its ROOT, which declares it once for every request`);
|
|
158
|
+
}
|
|
159
|
+
const judged = judgeRequestRoot(root, "the request root");
|
|
160
|
+
if (judged === undefined) {
|
|
161
|
+
throw new BrainError("invalid_request", `no request root is declared, so no request URL can be built for "${suffix}" — set config.baseUrl or Model.baseUrl`);
|
|
162
|
+
}
|
|
163
|
+
const { parsed } = judged;
|
|
164
|
+
const tail = suffix.replace(/^\/+/, "");
|
|
165
|
+
if (tail === "")
|
|
166
|
+
return canonicalRootSpelling(parsed);
|
|
167
|
+
const base = parsed.pathname.replace(/\/+$/, "");
|
|
168
|
+
const composed = `${parsed.protocol}//${parsed.host}${base}/${tail}${parsed.search}`;
|
|
169
|
+
const reread = servableParse(composed);
|
|
170
|
+
if (reread === undefined || !reread.pathname.startsWith(base === "" ? "/" : `${base}/`)) {
|
|
171
|
+
throw new TypeError(`request URL: the endpoint suffix ${JSON.stringify(suffix)} does not stay under the request root's path ` +
|
|
172
|
+
`(${base === "" ? "/" : base}) — a suffix names an endpoint of that root, and one that walks out of it would ` +
|
|
173
|
+
`send the request, and the credential paired with that root, somewhere the pairing law never judged`);
|
|
174
|
+
}
|
|
175
|
+
return composed;
|
|
98
176
|
}
|
|
99
177
|
export function stripUrlCredentials(text) {
|
|
100
178
|
return text.replace(/([A-Za-z][A-Za-z0-9+.\-]*:\/\/)[^/\s"'@]*@/g, `$1${REDACTED_CREDENTIALS}@`);
|
|
101
179
|
}
|
|
102
180
|
export function resolveRequestRoot(modelBaseUrl, configBaseUrl, laneDefault = "") {
|
|
103
|
-
const entry =
|
|
104
|
-
const declared =
|
|
105
|
-
|
|
181
|
+
const entry = judgeRequestRoot(modelBaseUrl, "Model.baseUrl");
|
|
182
|
+
const declared = judgeRequestRoot(configBaseUrl, "config.baseUrl");
|
|
183
|
+
const chosen = (entry ?? declared)?.parsed ?? servableParse(laneDefault.trim());
|
|
184
|
+
return chosen === undefined ? laneDefault : canonicalRootSpelling(chosen);
|
|
106
185
|
}
|
|
107
186
|
export function hasAuthCarrier(headers) {
|
|
108
187
|
if (!headers)
|