@sema-agent/core 7.23.4 → 7.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/dist/agents/list-agents-tool.d.ts +8 -1
  3. package/dist/agents/list-agents-tool.js +26 -3
  4. package/dist/agents/observer.js +5 -8
  5. package/dist/agents/subagent.js +2 -2
  6. package/dist/agents/team.js +9 -0
  7. package/dist/brain/anthropic.js +3 -2
  8. package/dist/brain/circuit-breaker.js +3 -3
  9. package/dist/brain/degrading.js +11 -14
  10. package/dist/brain/errors.d.ts +35 -18
  11. package/dist/brain/errors.js +49 -31
  12. package/dist/brain/failover.js +2 -3
  13. package/dist/brain/open-responses.js +6 -5
  14. package/dist/brain/openai.js +6 -5
  15. package/dist/brain/route-adjudicator.d.ts +50 -15
  16. package/dist/brain/route-adjudicator.js +112 -20
  17. package/dist/brain/route-conformance.js +9 -0
  18. package/dist/brain/stream-engine.js +18 -10
  19. package/dist/core/arg-summary.js +17 -6
  20. package/dist/core/auto-compaction.d.ts +42 -1
  21. package/dist/core/auto-compaction.js +21 -1
  22. package/dist/core/auto-mode.js +2 -8
  23. package/dist/core/checkpoint-store.d.ts +4 -6
  24. package/dist/core/checkpoint-store.js +2 -2
  25. package/dist/core/exec-gate.js +3 -5
  26. package/dist/core/file-history-store.js +11 -10
  27. package/dist/core/hooks.d.ts +7 -0
  28. package/dist/core/mcp.js +7 -5
  29. package/dist/core/memory-engine/distiller.d.ts +7 -4
  30. package/dist/core/memory-engine/distiller.js +11 -3
  31. package/dist/core/memory.js +1 -0
  32. package/dist/core/permission-rule-model.js +8 -5
  33. package/dist/core/runner/assemble-result.js +12 -19
  34. package/dist/core/runner/contracts.d.ts +1 -0
  35. package/dist/core/runner/prepare-delegation-surface.d.ts +3 -0
  36. package/dist/core/runner/prepare-delegation-surface.js +1 -0
  37. package/dist/core/runner/prepare-file-history.js +19 -0
  38. package/dist/core/runner/prepare-hands-readface.d.ts +4 -1
  39. package/dist/core/runner/prepare-hands-readface.js +4 -0
  40. package/dist/core/runner/prepare-task.js +2 -2
  41. package/dist/core/runner/run-leg.js +2 -13
  42. package/dist/core/runner/run-recovery-lanes.js +4 -20
  43. package/dist/core/runner/run-settle-and-teardown.js +2 -16
  44. package/dist/core/runner/run-turn-boundary.js +4 -20
  45. package/dist/core/runner/runtask.js +3 -5
  46. package/dist/core/runner/stream-settle-backstop.js +5 -5
  47. package/dist/core/runner/tool-end-body.js +5 -4
  48. package/dist/core/runner/tool-output-projection.js +3 -2
  49. package/dist/core/runner/turn-attachments.d.ts +0 -20
  50. package/dist/core/runner/turn-attachments.js +41 -36
  51. package/dist/core/session-store.js +2 -4
  52. package/dist/core/session.d.ts +4 -1
  53. package/dist/core/session.js +2 -1
  54. package/dist/core/task-event.d.ts +19 -9
  55. package/dist/core/task-registry-shared.js +1 -0
  56. package/dist/core/thrown-value.d.ts +52 -11
  57. package/dist/core/thrown-value.js +35 -1
  58. package/dist/core/tool-catalog-entries.js +1 -1
  59. package/dist/core/tool-errors.d.ts +6 -0
  60. package/dist/core/tool-errors.js +6 -14
  61. package/dist/core/tool-model-gate.js +3 -15
  62. package/dist/core/tools.js +15 -16
  63. package/dist/core/untrusted-text.d.ts +6 -0
  64. package/dist/core/untrusted-text.js +1 -0
  65. package/dist/core/workflow-run-store-contract.js +13 -0
  66. package/dist/core/workflow-run-store.d.ts +15 -0
  67. package/dist/core/workflow-run-store.js +1 -0
  68. package/dist/engine/llm/types.d.ts +4 -4
  69. package/dist/engine/loop/agent-loop.js +10 -24
  70. package/dist/index.d.ts +1 -1
  71. package/dist/index.js +1 -1
  72. package/dist/orchestration/workflow.js +44 -23
  73. package/dist/stores/file/file-history-store.js +12 -11
  74. package/dist/tools/fs/bash-program-position.d.ts +23 -0
  75. package/dist/tools/fs/bash-program-position.js +11 -0
  76. package/dist/tools/fs/bash-readonly-classifier.d.ts +30 -7
  77. package/dist/tools/fs/bash-readonly-classifier.js +31 -10
  78. package/dist/tools/fs/fs-bash.d.ts +30 -1
  79. package/dist/tools/fs/fs-bash.js +123 -54
  80. package/dist/tools/fs/read-deny.d.ts +14 -0
  81. package/dist/tools/fs/read-deny.js +5 -0
  82. package/dist/tools/fs/safety.d.ts +1 -1
  83. package/dist/tools/fs/safety.js +2 -1
  84. package/dist/tools/monitor.d.ts +5 -0
  85. package/dist/tools/monitor.js +7 -0
  86. package/package.json +1 -1
  87. package/test/export-surface.snapshot.json +7 -3
  88. package/wire-changes/core@7.23.5.json +133 -0
  89. package/wire-changes/core@7.24.0.json +249 -0
@@ -1,4 +1,4 @@
1
- import { thrownText } from "../core/thrown-value.js";
1
+ import { isErrorLike, readMember, thrownText } from "../core/thrown-value.js";
2
2
  export const ZERO_USAGE = {
3
3
  input: 0,
4
4
  output: 0,
@@ -82,16 +82,12 @@ const STALE_CONNECTION_CODES = new Set([
82
82
  "StreamSuspended",
83
83
  ]);
84
84
  function hasStaleConnectionCode(e) {
85
- try {
86
- let cur = e;
87
- for (let depth = 0; depth < 5 && cur !== undefined && cur !== null; depth++) {
88
- const code = typeof cur.code === "string" ? cur.code : undefined;
89
- if (code !== undefined && STALE_CONNECTION_CODES.has(code))
90
- return true;
91
- cur = cur instanceof Error ? cur.cause : undefined;
92
- }
93
- }
94
- catch {
85
+ let cur = e;
86
+ for (let depth = 0; depth < 5 && cur !== undefined && cur !== null; depth++) {
87
+ const code = readMember(cur, "code");
88
+ if (typeof code === "string" && STALE_CONNECTION_CODES.has(code))
89
+ return true;
90
+ cur = isErrorLike(cur) ? readMember(cur, "cause") : undefined;
95
91
  }
96
92
  return false;
97
93
  }
@@ -104,25 +100,26 @@ function walkConnectFailure(node, budget) {
104
100
  if (node === undefined || node === null)
105
101
  return "silent";
106
102
  let verdict = "silent";
107
- const code = node.code;
103
+ const code = readMember(node, "code");
108
104
  if (typeof code === "string") {
109
105
  if (!DETERMINISTIC_CONNECT_CODES.has(code))
110
106
  return "indeterminate";
111
107
  verdict = "deterministic";
112
108
  }
113
- if (!(node instanceof Error))
109
+ if (!isErrorLike(node))
114
110
  return verdict;
115
- const members = node.errors;
116
- if (Array.isArray(members)) {
117
- if (members.length === 0)
111
+ const members = readMember(node, "errors");
112
+ const legCount = readMember(members, "length");
113
+ if (members !== undefined) {
114
+ if (typeof legCount !== "number" || !Number.isInteger(legCount) || legCount <= 0)
118
115
  return "indeterminate";
119
- for (const member of members) {
120
- if (walkConnectFailure(member, budget) !== "deterministic")
116
+ for (let i = 0; i < legCount; i++) {
117
+ if (walkConnectFailure(readMember(members, String(i)), budget) !== "deterministic")
121
118
  return "indeterminate";
122
119
  }
123
120
  verdict = "deterministic";
124
121
  }
125
- const cause = node.cause;
122
+ const cause = readMember(node, "cause");
126
123
  if (cause !== undefined && cause !== null) {
127
124
  const below = walkConnectFailure(cause, budget);
128
125
  if (below === "indeterminate")
@@ -135,21 +132,42 @@ function walkConnectFailure(node, budget) {
135
132
  export function classifyConnectFailure(e) {
136
133
  return walkConnectFailure(e, { left: CONNECT_GRAPH_NODE_BUDGET }) === "deterministic" ? "deterministic" : "indeterminate";
137
134
  }
135
+ const FETCH_TRANSPORT_SHELLS = new Set(["fetch failed", "terminated"]);
136
+ const REQUEST_ARGUMENT_CODES = new Set(["ERR_INVALID_URL"]);
137
+ export function classifyFetchThrow(e) {
138
+ if (readMember(e, "name") !== "TypeError")
139
+ return "transport";
140
+ const message = readMember(e, "message");
141
+ if (typeof message === "string" && FETCH_TRANSPORT_SHELLS.has(message))
142
+ return "transport";
143
+ const cause = readMember(e, "cause");
144
+ if (cause === undefined || cause === null)
145
+ return "request_unbuildable";
146
+ const code = readMember(cause, "code");
147
+ return typeof code === "string" && REQUEST_ARGUMENT_CODES.has(code) ? "request_unbuildable" : "transport";
148
+ }
149
+ export function abortLikeName(err) {
150
+ if (!isErrorLike(err))
151
+ return undefined;
152
+ const name = readMember(err, "name");
153
+ return name === "AbortError" || name === "TimeoutError" ? name : undefined;
154
+ }
155
+ export function brainErrorCodeOf(e) {
156
+ const code = readMember(e, "code");
157
+ return typeof code === "string" && BRAIN_ERROR_CODES.includes(code) ? code : undefined;
158
+ }
138
159
  export function describeNetworkError(e) {
139
160
  const top = thrownText(e);
140
161
  const parts = [];
141
- try {
142
- let cur = e instanceof Error ? e.cause : undefined;
143
- for (let depth = 0; depth < 4 && cur !== undefined && cur !== null; depth++) {
144
- const code = typeof cur.code === "string" ? cur.code : undefined;
145
- const msg = thrownText(cur);
146
- const piece = code !== undefined && !msg.includes(code) ? `${code}: ${msg}` : msg;
147
- if (piece.length > 0 && piece !== top && !parts.includes(piece))
148
- parts.push(piece);
149
- cur = cur instanceof Error ? cur.cause : undefined;
150
- }
151
- }
152
- catch {
162
+ let cur = isErrorLike(e) ? readMember(e, "cause") : undefined;
163
+ for (let depth = 0; depth < 4 && cur !== undefined && cur !== null; depth++) {
164
+ const rawCode = readMember(cur, "code");
165
+ const code = typeof rawCode === "string" ? rawCode : undefined;
166
+ const msg = thrownText(cur);
167
+ const piece = code !== undefined && !msg.includes(code) ? `${code}: ${msg}` : msg;
168
+ if (piece.length > 0 && piece !== top && !parts.includes(piece))
169
+ parts.push(piece);
170
+ cur = isErrorLike(cur) ? readMember(cur, "cause") : undefined;
153
171
  }
154
172
  const body = parts.length > 0 ? `${top} (${parts.join(" ← ")})` : top;
155
173
  return hasStaleConnectionCode(e) ? `${body} — possibly a stale connection; a fresh one may succeed` : body;
@@ -1,5 +1,5 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
- import { extractErrorCode } from "./errors.js";
2
+ import { abortLikeName, extractErrorCode } from "./errors.js";
3
3
  import { emitBrainTelemetry } from "./status-sink.js";
4
4
  import { passthroughStream } from "./degrading.js";
5
5
  import { thrownText } from "../core/thrown-value.js";
@@ -65,8 +65,7 @@ export function createFailoverBrain(brains) {
65
65
  }
66
66
  }
67
67
  catch (e) {
68
- const aborted = options?.signal?.aborted === true ||
69
- (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError"));
68
+ const aborted = options?.signal?.aborted === true || abortLikeName(e) !== undefined;
70
69
  const errorMsg = {
71
70
  role: "assistant",
72
71
  content: [{ type: "text", text: "" }],
@@ -6,7 +6,7 @@ import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
8
  import { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
9
- import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
9
+ import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
10
10
  import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
11
11
  import { runStreamingBrain } from "./stream-engine.js";
12
12
  const DEGENERATE_POLL_CHARS = 64;
@@ -217,6 +217,7 @@ function mapTerminalStopReason(incompleteReason, hadToolCalls) {
217
217
  return "stop";
218
218
  }
219
219
  export function createOpenResponsesBrain(config = {}) {
220
+ assertServableRequestRoot(config.baseUrl, "config.baseUrl");
220
221
  const doFetch = config.fetchImpl ?? fetch;
221
222
  const stream = (model, context, options) => {
222
223
  let sentMaxTokens;
@@ -232,6 +233,10 @@ export function createOpenResponsesBrain(config = {}) {
232
233
  httpLabel: "responses",
233
234
  stallTimeouts: options?.stallTimeouts,
234
235
  buildRequest: (overrides) => {
236
+ const root = resolveRequestRoot(model.baseUrl, config.baseUrl);
237
+ if (!root) {
238
+ throw new Error("createOpenResponsesBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
239
+ }
235
240
  const routeCredential = resolveRouteCredential({
236
241
  optionsApiKey: options?.apiKey,
237
242
  optionsHeaders: options?.headers,
@@ -243,10 +248,6 @@ export function createOpenResponsesBrain(config = {}) {
243
248
  if (!routeVerdict.ok)
244
249
  throw new BrainError("invalid_request", routeRefusalText(routeVerdict));
245
250
  const apiKey = routeCredential.apiKey;
246
- const root = (model.baseUrl || config.baseUrl || "").replace(/\/+$/, "");
247
- if (!root) {
248
- throw new Error("createOpenResponsesBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
249
- }
250
251
  if (options?.stop !== undefined && options.stop.length > 0) {
251
252
  throw new Error("createOpenResponsesBrain: options.stop is not expressible in the Open Responses request (the wire form has no stop-sequence field); " +
252
253
  "remove it, or serve this model through the chat-completions lane, which does support it");
@@ -7,7 +7,7 @@ import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
8
  import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
9
9
  import { BrainError } from "./errors.js";
10
- import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
10
+ import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
11
11
  import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
12
12
  import { runStreamingBrain } from "./stream-engine.js";
13
13
  function closeToolCallAccum(acc) {
@@ -281,6 +281,7 @@ export function inferMaxTokensField(modelId) {
281
281
  return "max_tokens";
282
282
  }
283
283
  export function createOpenAIBrain(config = {}) {
284
+ assertServableRequestRoot(config.baseUrl, "config.baseUrl");
284
285
  const doFetch = config.fetchImpl ?? fetch;
285
286
  const stream = (model, context, options) => {
286
287
  let sentMaxTokens;
@@ -295,6 +296,10 @@ export function createOpenAIBrain(config = {}) {
295
296
  httpLabel: "gateway",
296
297
  stallTimeouts: options?.stallTimeouts,
297
298
  buildRequest: (overrides) => {
299
+ const root = resolveRequestRoot(model.baseUrl, config.baseUrl);
300
+ if (!root) {
301
+ throw new Error("createOpenAIBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
302
+ }
298
303
  const routeCredential = resolveRouteCredential({
299
304
  optionsApiKey: options?.apiKey,
300
305
  optionsHeaders: options?.headers,
@@ -306,10 +311,6 @@ export function createOpenAIBrain(config = {}) {
306
311
  if (!routeVerdict.ok)
307
312
  throw new BrainError("invalid_request", routeRefusalText(routeVerdict));
308
313
  const apiKey = routeCredential.apiKey;
309
- const root = (model.baseUrl || config.baseUrl || "").replace(/\/+$/, "");
310
- if (!root) {
311
- throw new Error("createOpenAIBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
312
- }
313
314
  const body = {
314
315
  model: model.id,
315
316
  messages: toOpenAIMessages(context, config.replayThinking, model),
@@ -27,29 +27,64 @@
27
27
  * deployment root — a distinct endpoint with no credential route is a configuration hole, and a
28
28
  * loud refusal with a fix hint beats an unauthenticated request to a third-party host.
29
29
  *
30
- * Fail-closed direction: "no credential leak" outranks "silently works". A deployment that only
31
- * worked because the gateway key HAPPENED to be valid at another entry's host now refuses loudly,
32
- * and every refusal names the entry, both URL halves, and a one-line fix.
30
+ * Fail-closed direction: "no credential leak" outranks "silently works", and every refusal names the
31
+ * entry, both URL halves, and a one-line fix.
33
32
  *
34
- * Bad-value loudness (garbage / contradiction / absence):
33
+ * Bad-value loudness (garbage / contradiction / absence / misplacement):
35
34
  * - garbage — non-string URL/credential inputs throw a `TypeError` here (a programmer error must
36
35
  * not be laundered into a polite verdict); whitespace-only URLs normalize to "" (undeclared).
37
36
  * - contradiction — the torn pair refuses with `route.credential_mismatch` + both halves named.
38
37
  * - absence — `route.credential_missing` with the fix hint, exactly where absence is a hole.
38
+ * - misplacement — a credential in the URL, or a non-http(s) root: {@link assertServableRequestRoot}.
39
39
  */
40
40
  import type { Model } from "../internal/llm.js";
41
41
  import type { RouteAdjudication, RouteCredential, RoutePairingConfig, RoutePairingPosture } from "../internal/llm.js";
42
42
  import type { Brain, EngineNotice } from "../core/types.js";
43
- /** Normalize a base URL for pairing comparison, to WIRE equivalence: trim + strip trailing
44
- * slashes, then — for a string that parses as a URL — fold the halves the wire itself treats as
45
- * case-insensitive (scheme and host, RFC 9110) and elide the scheme's default port, while keeping
46
- * the PATH byte-preserved (paths are case-sensitive; folding them would compare two genuinely
47
- * different endpoints equal — a widening of exactly the leak this module exists to stop). Two
48
- * spellings that resolve to the same origin+path on the wire therefore compare equal, and a
49
- * refusal can never be minted over a spelling difference the wire cannot see. A value that does
50
- * not parse as a URL keeps the raw trimmed spelling — the same fail-closed raw comparison as
51
- * before. Non-string input throws — see the module note on garbage. */
52
- export declare function normalizeBaseUrl(url: string | undefined): string;
43
+ /** A base URL's ROUTE KEY — the value the pairing law COMPARES, folded to WIRE equivalence: the
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 (a query selects the endpoint as
46
+ * surely as a path does; folding either would compare two different endpoints equal — the widening
47
+ * this module exists to stop). An unparseable value keeps its raw trimmed spelling: fail-closed raw
48
+ * comparison. Non-string input throws — see the module note on garbage.
49
+ *
50
+ * **Never render this.** Byte-preserving on the query means `?api_key=<secret>` rides it verbatim;
51
+ * {@link displayUrlOf} is the one spelling allowed into text. */
52
+ export declare function routeKeyOf(url: string | undefined): string;
53
+ /** A base URL's DISPLAY spelling — the only rendering of a request root allowed into text a human,
54
+ * a log or a consumer sees. Composed from the PARSE (never edited out of raw bytes): userinfo
55
+ * drops, EVERY query value becomes {@link REDACTED_VALUE}, and a query NAME rides only where it
56
+ * really is a name.
57
+ * - no secret-name list: a list has a blind spot at the name nobody listed.
58
+ * - names ride because dropping the query WHOLE renders two roots differing only in query as one
59
+ * string — exactly the pairs the law refuses, so its refusal would read "targets X … paired
60
+ * with X". {@link queryDifferenceNote} closes the case where the names match too.
61
+ * - a BARE token (`?<token>`) renders as one placeholder with NO name: `URLSearchParams` calls it a
62
+ * name with an empty value, and that reading put the payload itself on screen. "Every value is
63
+ * withheld" protects only what IS a value, so the raw segment decides what rides.
64
+ * Answers `""` for an undeclared root, like its key twin. */
65
+ export declare function displayUrlOf(url: string | undefined): string;
66
+ /**
67
+ * The bad-value refusal every base-URL seat owes: **a request root must be an absolute http(s) URL, and
68
+ * it may not carry the credential itself.**
69
+ *
70
+ * Both halves fail the same way if they are not caught here — as a TRANSPORT failure, indistinguishable
71
+ * from an outage, retried for the whole backoff ladder. Measured: `file:`/`ftp:` reject with
72
+ * `TypeError("fetch failed")` over a causeless "unknown scheme", the exact shell a refused connection uses.
73
+ * Stripping userinfo instead of refusing would send an UNAUTHENTICATED request to the host the operator
74
+ * thought they had authenticated to. Refused where the value ARRIVES — construction for `config.baseUrl`,
75
+ * request build for `Model.baseUrl`. An undeclared root is not this rule's question; the PAIRING law keeps
76
+ * its tolerance for an unparseable spelling ({@link routeKeyOf}). Answers the value it judged.
77
+ */
78
+ export declare function assertServableRequestRoot(url: string | undefined, seat: string): string | undefined;
79
+ /** Remove the userinfo from every authority in free text, WHOLE — the general redactor keeps a short or
80
+ * unprefixed username, and `TOKEN@host` / `TOKEN:x-oauth-basic@host` are the canonical PAT spellings. */
81
+ export declare function stripUrlCredentials(text: string): string;
82
+ /** The request ROOT a brain targets — `Model.baseUrl` else `config.baseUrl` else the lane's default,
83
+ * trailing slashes stripped — with {@link assertServableRequestRoot} applied to both declared
84
+ * halves on the way. The three brains each carried a copy of this coalesce-and-strip line, so a rule
85
+ * about base URLs had three homes; now it has one. Answers `""` when nothing is declared and the lane
86
+ * has no default — whether that is a refusal is the caller's. */
87
+ export declare function resolveRequestRoot(modelBaseUrl: string | undefined, configBaseUrl: string | undefined, laneDefault?: string): string;
53
88
  /** Whether a header bag carries an auth-bearing header (any case). */
54
89
  export declare function hasAuthCarrier(headers: Record<string, string> | undefined): boolean;
55
90
  /**
@@ -168,7 +203,7 @@ export declare function adjudicateDerivedRoute(args: {
168
203
  }): Promise<RouteAdjudication | undefined>;
169
204
  /**
170
205
  * Whether two resolved models denote the SAME route for pre-flight purposes: identical wire id AND
171
- * identical normalized entry URL. `Model.id` alone is NOT route identity — two catalog entries can
206
+ * identical entry ROUTE KEY. `Model.id` alone is NOT route identity — two catalog entries can
172
207
  * legitimately share a wire id (the same model served from two endpoints), and a same-id guard
173
208
  * would silently skip the pre-flight for exactly the split the pairing law exists to judge.
174
209
  */
@@ -1,5 +1,28 @@
1
1
  import { AUTH_CARRIER_NAMES } from "./request-params.js";
2
- export function normalizeBaseUrl(url) {
2
+ import { BrainError } from "./errors.js";
3
+ const REDACTED_VALUE = "«redacted»";
4
+ const REDACTED_CREDENTIALS = "[redacted-credentials]";
5
+ function servableParse(raw) {
6
+ let u;
7
+ try {
8
+ u = new URL(raw);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ return u.protocol === "http:" || u.protocol === "https:" ? u : undefined;
14
+ }
15
+ function composableParse(raw) {
16
+ let u;
17
+ try {
18
+ u = new URL(raw);
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ return u.host !== "" || u.pathname.startsWith("/") ? u : undefined;
24
+ }
25
+ export function routeKeyOf(url) {
3
26
  if (url === undefined)
4
27
  return "";
5
28
  if (typeof url !== "string") {
@@ -8,13 +31,78 @@ export function normalizeBaseUrl(url) {
8
31
  const raw = url.trim().replace(/\/+$/, "");
9
32
  if (raw === "")
10
33
  return "";
11
- try {
12
- const u = new URL(raw);
13
- return `${u.protocol}//${u.host}${u.pathname}${u.search}`.replace(/\/+$/, "");
14
- }
15
- catch {
34
+ const u = servableParse(raw);
35
+ if (u === undefined)
16
36
  return raw;
37
+ return `${u.protocol}//${u.host}${u.pathname}${u.search}`.replace(/\/+$/, "");
38
+ }
39
+ function querySegments(search) {
40
+ if (search === "" || search === "?")
41
+ return [];
42
+ return search.replace(/^\?/, "").split("&").map((raw) => {
43
+ const eq = raw.indexOf("=");
44
+ return eq === -1 ? { raw } : { raw, name: raw.slice(0, eq), value: raw.slice(eq + 1) };
45
+ });
46
+ }
47
+ export function displayUrlOf(url) {
48
+ const key = routeKeyOf(url);
49
+ if (key === "")
50
+ return "";
51
+ const u = composableParse(key);
52
+ if (u === undefined) {
53
+ const cut = key.search(/[?#]/);
54
+ const head = cut === -1 ? key : key.slice(0, cut);
55
+ const at = head.lastIndexOf("@");
56
+ const shown = at === -1 ? head : `${REDACTED_VALUE}@${head.slice(at + 1)}`;
57
+ return cut === -1 ? shown : `${shown}${key[cut]}${REDACTED_VALUE}`;
17
58
  }
59
+ const segs = querySegments(u.search);
60
+ const query = segs.length === 0 ? "" : `?${segs.map((sg) => (sg.raw === "" ? "" : sg.name === undefined ? REDACTED_VALUE : `${sg.name}=${REDACTED_VALUE}`)).join("&")}`;
61
+ return `${u.protocol}//${u.host}${u.pathname}${query}`.replace(/\/+$/, "");
62
+ }
63
+ function queryDifferenceNote(requestKey, declaredKey) {
64
+ if (displayUrlOf(requestKey) !== displayUrlOf(declaredKey))
65
+ return "";
66
+ const names = new Set();
67
+ const ua = composableParse(requestKey);
68
+ const ub = composableParse(declaredKey);
69
+ if (ua !== undefined && ub !== undefined) {
70
+ const a = querySegments(ua.search);
71
+ const b = querySegments(ub.search);
72
+ const valuesOf = (segs, n) => segs.filter((sg) => sg.name === n).map((sg) => sg.value).join("\u0000");
73
+ for (const n of new Set([...a, ...b].flatMap((sg) => (sg.name === undefined ? [] : [sg.name])))) {
74
+ if (valuesOf(a, n) !== valuesOf(b, n))
75
+ names.add(n);
76
+ }
77
+ }
78
+ return names.size > 0
79
+ ? ` (same rendering — the query values differ under: ${[...names].sort().join(", ")})`
80
+ : ` (same rendering — the difference is in a part this line withholds or folds)`;
81
+ }
82
+ export function assertServableRequestRoot(url, seat) {
83
+ if (typeof url !== "string" || url.trim() === "")
84
+ return undefined;
85
+ const trimmed = url.trim();
86
+ const parsed = URL.canParse(trimmed) ? new URL(trimmed) : undefined;
87
+ if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
88
+ const named = parsed === undefined ? "it did not parse as a URL" : displayUrlOf(trimmed);
89
+ throw new BrainError("invalid_request", `${seat} is not a servable request root (${named}) — it must be an ABSOLUTE http(s) URL. Another scheme, ` +
90
+ `or a spelling that is not a URL at all, fails at the transport in a shape indistinguishable from an ` +
91
+ `outage, so it would be retried for the whole backoff ladder instead of refused here.`);
92
+ }
93
+ if (parsed.username === "" && parsed.password === "")
94
+ return trimmed;
95
+ throw new BrainError("invalid_request", `${seat} carries credentials in its authority (${parsed.protocol}//${REDACTED_CREDENTIALS}@${parsed.host}${parsed.pathname}) — ` +
96
+ `a request cannot be built from a URL with userinfo, so every call through this brain would fail: put the ` +
97
+ `credential on the apiKey knob or an auth header and leave the URL bare`);
98
+ }
99
+ export function stripUrlCredentials(text) {
100
+ return text.replace(/([A-Za-z][A-Za-z0-9+.\-]*:\/\/)[^/\s"'@]*@/g, `$1${REDACTED_CREDENTIALS}@`);
101
+ }
102
+ export function resolveRequestRoot(modelBaseUrl, configBaseUrl, laneDefault = "") {
103
+ const entry = assertServableRequestRoot(modelBaseUrl, "Model.baseUrl");
104
+ const declared = assertServableRequestRoot(configBaseUrl, "config.baseUrl");
105
+ return (entry ?? declared ?? laneDefault).replace(/\/+$/, "");
18
106
  }
19
107
  export function hasAuthCarrier(headers) {
20
108
  if (!headers)
@@ -50,9 +138,12 @@ export function resolveRouteCredential(input) {
50
138
  return { source: "absent" };
51
139
  }
52
140
  export function adjudicateModelRoute(model, credential, config) {
53
- const entry = normalizeBaseUrl(model.baseUrl);
54
- const declared = normalizeBaseUrl(config.baseUrl);
141
+ const entry = routeKeyOf(model.baseUrl);
142
+ const declared = routeKeyOf(config.baseUrl);
55
143
  const request = entry || declared;
144
+ const entryShown = displayUrlOf(entry);
145
+ const declaredShown = displayUrlOf(declared);
146
+ const requestShown = displayUrlOf(request);
56
147
  if (credential.source === "per-model")
57
148
  return { ok: true, posture: "per-model" };
58
149
  if (credential.source === "deployment-config") {
@@ -65,12 +156,13 @@ export function adjudicateModelRoute(model, credential, config) {
65
156
  code: "route.credential_mismatch",
66
157
  detail: {
67
158
  modelId: model.id,
68
- entryBaseUrl: entry,
69
- requestBaseUrl: request,
70
- configBaseUrl: declared,
71
- fixHint: `model "${model.id}" targets ${request} but the deployment credential is paired with ${declared}: ` +
159
+ entryBaseUrl: entryShown,
160
+ requestBaseUrl: requestShown,
161
+ configBaseUrl: declaredShown,
162
+ fixHint: `model "${model.id}" targets ${requestShown} but the deployment credential is paired with ${declaredShown}` +
163
+ `${queryDifferenceNote(request, declared)}: ` +
72
164
  `give the model its own credential (a per-model key via getApiKeyAndHeaders, or an auth header on Model.headers), ` +
73
- `or serve it through a brain whose config.baseUrl is ${request}`,
165
+ `or serve it through a brain whose config.baseUrl is the model's own root`,
74
166
  },
75
167
  };
76
168
  }
@@ -81,12 +173,12 @@ export function adjudicateModelRoute(model, credential, config) {
81
173
  code: "route.credential_missing",
82
174
  detail: {
83
175
  modelId: model.id,
84
- entryBaseUrl: entry,
85
- requestBaseUrl: request,
86
- configBaseUrl: declared,
87
- fixHint: `model "${model.id}" targets ${request} but no credential is configured for that endpoint ` +
88
- `(the deployment credential root is ${declared}): supply a per-model credential ` +
89
- `(getApiKeyAndHeaders, or an auth header on Model.headers), or serve it through a brain constructed for ${request}`,
176
+ entryBaseUrl: entryShown,
177
+ requestBaseUrl: requestShown,
178
+ configBaseUrl: declaredShown,
179
+ fixHint: `model "${model.id}" targets ${requestShown} but no credential is configured for that endpoint ` +
180
+ `(the deployment credential root is ${declaredShown})${queryDifferenceNote(request, declared)}: supply a per-model credential ` +
181
+ `(getApiKeyAndHeaders, or an auth header on Model.headers), or serve it through a brain constructed for that root`,
90
182
  },
91
183
  };
92
184
  }
@@ -166,7 +258,7 @@ export async function adjudicateDerivedRoute(args) {
166
258
  }
167
259
  }
168
260
  export function sameRouteIdentity(a, b) {
169
- return a.id === b.id && normalizeBaseUrl(a.baseUrl) === normalizeBaseUrl(b.baseUrl);
261
+ return a.id === b.id && routeKeyOf(a.baseUrl) === routeKeyOf(b.baseUrl);
170
262
  }
171
263
  export function fallbackToPrimaryNotice(args) {
172
264
  return {
@@ -73,6 +73,15 @@ export const ROUTE_ADJUDICATION_CONFORMANCE_CORPUS = [
73
73
  expect: { ok: false, code: "route.credential_mismatch" },
74
74
  leg: "named",
75
75
  },
76
+ {
77
+ name: "deployment-mismatch-query-differs",
78
+ intent: "two roots differing only in a QUERY parameter are two routes — the deployment credential is not paired with the second",
79
+ model: { id: "m-tenant", baseUrl: "https://gw.example.com/v1?tenant=b-corp" },
80
+ credential: { source: "deployment-config", apiKey: "k-boot" },
81
+ config: { baseUrl: "https://gw.example.com/v1?tenant=a-corp" },
82
+ expect: { ok: false, code: "route.credential_mismatch" },
83
+ leg: "named",
84
+ },
76
85
  {
77
86
  name: "deployment-mismatch-derived-leg",
78
87
  intent: "the same torn pair on a DERIVED model: same verdict; the engine disposes of it as fallback-to-primary + notice",
@@ -1,12 +1,14 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { STREAM_TORN_WARNING } from "./stream-shared.js";
3
3
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
4
- import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, namesTheFailure, readProviderRequestId } from "./errors.js";
4
+ import { abortLikeName, BrainError, brainErrorCodeOf, classifyConnectFailure, classifyFetchThrow, classifyHttp, describeNetworkError, namesTheFailure, readProviderRequestId } from "./errors.js";
5
+ import { stripUrlCredentials } from "./route-adjudicator.js";
5
6
  import { classifyInputTooLong } from "./input-too-long.js";
6
7
  import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
7
8
  import { bindBrainStatusEmitter, emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
8
9
  import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
9
- import { thrownText } from "../core/thrown-value.js";
10
+ import { readMember, thrownText } from "../core/thrown-value.js";
11
+ import { redactSecrets } from "../core/untrusted-egress.js";
10
12
  const DEFAULT_MAX_RETRIES = 10;
11
13
  const MAX_RETRIES_ENV_CEILING = 15;
12
14
  const MAX_RETRIES_ENV = "SEMA_MAX_RETRIES";
@@ -215,7 +217,7 @@ function emptyAssistant(model) {
215
217
  };
216
218
  }
217
219
  function isAbortError(err) {
218
- return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
220
+ return abortLikeName(err) !== undefined;
219
221
  }
220
222
  function sleep(ms, signal) {
221
223
  return new Promise((resolve) => {
@@ -272,15 +274,17 @@ export function runStreamingBrain(args) {
272
274
  const errorMsg = emptyAssistant(model);
273
275
  errorMsg.stopReason = aborted ? "aborted" : "error";
274
276
  errorMsg.errorMessage = thrownText(err);
275
- if (!aborted && err instanceof BrainError) {
276
- if (err.inputTooLong === true)
277
+ if (!aborted && brainErrorCodeOf(err) !== undefined) {
278
+ if (readMember(err, "inputTooLong") === true)
277
279
  errorMsg.errorKind = "input_too_long";
278
- else if (err.inputTooLongRuledOut === true)
280
+ else if (readMember(err, "inputTooLongRuledOut") === true)
279
281
  errorMsg.inputTooLongRuledOut = true;
280
- if (err.apiError === true) {
282
+ if (readMember(err, "apiError") === true) {
281
283
  errorMsg.isApiErrorMessage = true;
282
- if (namesTheFailure(err.status))
283
- errorMsg.apiErrorStatus = err.status;
284
+ const status = readMember(err, "status");
285
+ const statusNum = typeof status === "number" ? status : undefined;
286
+ if (namesTheFailure(statusNum))
287
+ errorMsg.apiErrorStatus = statusNum;
284
288
  if (requestIdSeen !== undefined)
285
289
  errorMsg.requestId = requestIdSeen;
286
290
  }
@@ -338,6 +342,10 @@ export function runStreamingBrain(args) {
338
342
  cc.dispose();
339
343
  throw e;
340
344
  }
345
+ else if (classifyFetchThrow(e) === "request_unbuildable") {
346
+ cc.dispose();
347
+ throw new BrainError("invalid_request", `${httpLabel} request could not be built: ${redactSecrets(stripUrlCredentials(thrownText(e)))}`);
348
+ }
341
349
  else {
342
350
  netErr = e;
343
351
  }
@@ -575,7 +583,7 @@ export function runStreamingBrain(args) {
575
583
  return false;
576
584
  }
577
585
  catch (e) {
578
- if (!(e instanceof BrainError) || e.code !== "server" || !parser.snapshot().hasSubstantiveText)
586
+ if (brainErrorCodeOf(e) !== "server" || !parser.snapshot().hasSubstantiveText)
579
587
  throw e;
580
588
  return true;
581
589
  }
@@ -196,14 +196,15 @@ export function runRedactionPasses(input, passes, report) {
196
196
  return result;
197
197
  }
198
198
  export const SECRET_PASSES = [
199
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])(?:sk|pk|rk|gh[opsur])[-_][A-Za-z0-9_-]{8,}/g, replace: "[redacted]" },
199
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])(?:sk|pk|rk|gh[opsur]|github_pat)[-_][A-Za-z0-9_-]{8,}/g, replace: "[redacted]" },
200
200
  { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])AIza[A-Za-z0-9_-]{20,}/g, replace: "[redacted]" },
201
201
  { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{10,}/g, replace: "[redacted]" },
202
202
  { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{12,}/g, replace: "[redacted]" },
203
203
  { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}/g, replace: "[redacted]" },
204
204
  { kind: "jwt", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9._-]{20,}/g, replace: "[redacted]" },
205
- { kind: "private-key-block", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?(?:-----END[ A-Z]*PRIVATE KEY-----|$)/g, replace: "[redacted]" },
205
+ { kind: "private-key-block", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /-----BEGIN[ A-Z0-9]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END[ A-Z0-9]*PRIVATE KEY(?: BLOCK)?-----|$)/g, replace: "[redacted]" },
206
206
  { kind: "bearer-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])Bearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, replace: "[redacted]" },
207
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(AccountKey=)[A-Za-z0-9+/]{40,}={0,2}/g, replace: "$1[redacted]" },
207
208
  {
208
209
  kind: "keyword-value",
209
210
  confidence: "medium",
@@ -247,12 +248,22 @@ export function primaryActivityArg(args) {
247
248
  val = basename(path);
248
249
  }
249
250
  else if (url !== undefined) {
250
- try {
251
- const u = new URL(url);
252
- val = `${u.origin}${u.pathname}`;
251
+ const parsed = (() => { try {
252
+ return new URL(url);
253
253
  }
254
254
  catch {
255
- val = url.split(/[?#]/)[0].replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, "$1");
255
+ return undefined;
256
+ } })();
257
+ if (parsed !== undefined && parsed.host !== "") {
258
+ val = `${parsed.origin}${parsed.pathname}`;
259
+ }
260
+ else {
261
+ const head = url.split(/[?#]/, 1)[0] ?? url;
262
+ const sep = head.indexOf("://");
263
+ const scheme = sep > 0 && /^[a-z][a-z0-9+.-]*$/i.test(head.slice(0, sep)) ? head.slice(0, sep + 3) : "";
264
+ const rest = head.slice(scheme.length);
265
+ const at = rest.lastIndexOf("@");
266
+ val = scheme + (at === -1 ? rest : rest.slice(at + 1));
256
267
  }
257
268
  }
258
269
  else {