@sema-agent/core 5.49.0 → 5.51.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.
- package/CHANGELOG.md +112 -0
- package/dist/agents/roster-store.js +4 -1
- package/dist/agents/send-message-tool.js +5 -5
- package/dist/agents/subagent.d.ts +6 -0
- package/dist/agents/subagent.js +142 -5
- package/dist/agents/teacher.js +4 -1
- package/dist/brain/anthropic.js +11 -20
- package/dist/brain/open-responses.js +6 -14
- package/dist/brain/openai.js +6 -18
- package/dist/brain/reasoning.d.ts +100 -8
- package/dist/brain/reasoning.js +39 -15
- package/dist/brain/request-params.d.ts +37 -1
- package/dist/brain/request-params.js +40 -2
- package/dist/core/auto-mode-prompt.js +9 -1
- package/dist/core/hooks.d.ts +24 -1
- package/dist/core/hooks.js +26 -4
- package/dist/core/mcp.d.ts +7 -1
- package/dist/core/mcp.js +64 -8
- package/dist/core/memory-engine/engine.d.ts +30 -1
- package/dist/core/memory-engine/engine.js +219 -18
- package/dist/core/memory-engine/layout.d.ts +43 -0
- package/dist/core/memory-engine/layout.js +59 -0
- package/dist/core/memory-engine/memory-backend-contract.js +87 -0
- package/dist/core/memory-engine/types.d.ts +13 -1
- package/dist/core/runner/assemble-result.d.ts +6 -0
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/prepare-task.d.ts +15 -0
- package/dist/core/runner/prepare-task.js +104 -44
- package/dist/core/runner/runtask.d.ts +5 -1
- package/dist/core/runner/runtask.js +14 -7
- package/dist/core/task-registry-agent.js +9 -3
- package/dist/core/task-registry-shared.d.ts +6 -0
- package/dist/core/task-registry.js +4 -2
- package/dist/core/tool-policy.d.ts +37 -0
- package/dist/core/tool-policy.js +36 -3
- package/dist/core/tools.js +7 -0
- package/dist/core/types.d.ts +53 -1
- package/dist/engine/loop/agent-loop.js +95 -30
- package/dist/engine/loop/types.d.ts +32 -0
- package/dist/orchestration/run-workflow-tool.d.ts +12 -0
- package/dist/orchestration/run-workflow-tool.js +1 -1
- package/dist/orchestration/workflow-governance.d.ts +27 -0
- package/dist/orchestration/workflow-governance.js +13 -0
- package/dist/orchestration/workflow-primitives.d.ts +8 -1
- package/dist/orchestration/workflow-primitives.js +11 -3
- package/package.json +1 -1
|
@@ -16,6 +16,29 @@ import type { ThinkingLevel } from "../internal/harness-types.js";
|
|
|
16
16
|
export type ReasoningIntensity = ThinkingLevel;
|
|
17
17
|
/** Type guard: is `v` one of the 7 {@link ThinkingLevel} tiers? (A legacy/unknown reasoning string is not.) */
|
|
18
18
|
export declare function isThinkingLevel(v: unknown): v is ThinkingLevel;
|
|
19
|
+
/**
|
|
20
|
+
* THE thinking-request ENTRY predicate — does this request enter an applier's emission path at all?
|
|
21
|
+
* One conjunction, three arms: the model declares reasoning (TRUTHINESS — the adapters' own
|
|
22
|
+
* judgment, see the r5 note in {@link resolveReasoning}), the requested value is a real
|
|
23
|
+
* {@link ThinkingLevel} (an out-of-contract value — a typo'd tier, a caller's own enum — reads as
|
|
24
|
+
* ABSENCE, never as a declared tier), and it is not the explicit `"off"`.
|
|
25
|
+
*
|
|
26
|
+
* ENTRY gate, deliberately NOT an emitted-a-key guarantee: past this gate the per-FORMAT arms still
|
|
27
|
+
* decide what (if anything) lands on the wire — `supportsReasoningEffort:false` on a format whose
|
|
28
|
+
* only carrier is the effort key, a `null` levelmap entry, the binary enable keys. Those arms have
|
|
29
|
+
* their own reporting shape (the `graded:false` intent-echo family, each pinned with its rationale
|
|
30
|
+
* where it lives); this predicate only closes the gate the three appliers used to re-spell.
|
|
31
|
+
*
|
|
32
|
+
* Single-sourced here because every wire applier (openai.ts `applyThinking`, anthropic.ts's thinking
|
|
33
|
+
* + effort arms, open-responses.ts `resolveWireEffort`) used to re-spell the same three conjuncts —
|
|
34
|
+
* and the REPORTING resolver ({@link resolveReasoning}) mirrored only ONE of them (`!model.reasoning`),
|
|
35
|
+
* so a garbage tier put ZERO parameters on the wire while the resolution claimed a clamped-to-minimal
|
|
36
|
+
* gradient (or echoed the garbage string as `effective` on binary formats). A predicate the appliers
|
|
37
|
+
* and the reporter both call cannot drift.
|
|
38
|
+
*/
|
|
39
|
+
export declare function reasoningRequestCarried(model: {
|
|
40
|
+
reasoning?: boolean;
|
|
41
|
+
}, reasoning: unknown): reasoning is ThinkingLevel;
|
|
19
42
|
/** Ordinal rank of a level (`off`=0 … `max`=6). */
|
|
20
43
|
export declare function rankOf(level: ThinkingLevel): number;
|
|
21
44
|
/**
|
|
@@ -65,6 +88,66 @@ export interface ReasoningResolution {
|
|
|
65
88
|
/** True when {@link effective} differs from {@link requested} (the request couldn't be honored exactly). */
|
|
66
89
|
clamped: boolean;
|
|
67
90
|
}
|
|
91
|
+
/** Anthropic's hard floor for an extended-thinking budget. Lives HERE (not the anthropic brain) so
|
|
92
|
+
* the cap-wins predicate below and the brain's budget-window math read ONE constant. */
|
|
93
|
+
export declare const MIN_THINKING_TOKENS = 1024;
|
|
94
|
+
/**
|
|
95
|
+
* The anthropic BUDGET path's cap-wins arm (#346, single-sourced; design/119 #2 review codex H4):
|
|
96
|
+
* a HARD per-request output cap (an engine-imposed override or the caller's explicit
|
|
97
|
+
* `options.maxTokens` — the two lanes the brain refuses to raise) too small to host a legal thinking
|
|
98
|
+
* budget (≥ {@link MIN_THINKING_TOKENS}) plus answer room means the CAP WINS and thinking is skipped
|
|
99
|
+
* for the request. Shared by the wire arm (anthropic.ts, which acts on it) and
|
|
100
|
+
* {@link resolveReasoning}'s budget arm (which mirrors it when the caller supplies the request
|
|
101
|
+
* facts), so the skip decision and its report are one predicate — the reporter's budget arm used to
|
|
102
|
+
* claim an unconditional `graded:true` gradient while this arm deleted the thinking block from the
|
|
103
|
+
* very request it described. A soft (model/config-sourced) cap never skips: the brain raises it to
|
|
104
|
+
* host the budget instead (`hardCap === false`).
|
|
105
|
+
*/
|
|
106
|
+
export declare function budgetCapSkipsThinking(outputCapTokens: number, hardCap: boolean): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* OPTIONAL per-request facts for {@link resolveReasoning} — what the wire's budget arm knows at
|
|
109
|
+
* request build that a per-leg eager resolution cannot: the resolved output cap and whether it is a
|
|
110
|
+
* HARD bound. Supplied ⇒ the anthropic budget arm mirrors the wire's cap-wins skip
|
|
111
|
+
* ({@link budgetCapSkipsThinking}); absent ⇒ the budget arm reports the cap-blind gradient it always
|
|
112
|
+
* did (the eager per-leg trace/result mint has no request facts — a capped request's per-attempt skip
|
|
113
|
+
* is visible only to a caller that passes them).
|
|
114
|
+
*/
|
|
115
|
+
export interface ReasoningWireFacts {
|
|
116
|
+
/** The request's resolved output cap (the wire `max_tokens` at the moment the thinking arm judges). */
|
|
117
|
+
outputCapTokens: number;
|
|
118
|
+
/** True when the cap is HARD (engine override / caller `options.maxTokens`) — the lanes the brain
|
|
119
|
+
* refuses to raise; a soft model/config cap is raised to host the budget instead. */
|
|
120
|
+
hardOutputCap: boolean;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The anthropic-`effortLevels` PRESENCE/SHAPE gate, shared by the wire arm (anthropic.ts) and the
|
|
124
|
+
* reporting dispatch below so the two cannot disagree about which family a request rides (#335): the
|
|
125
|
+
* old twin predicates were `x && x.length > 0` on both sides, so a truthy NON-ARRAY (a string —
|
|
126
|
+
* `.length > 0` holds) entered the wire arm and threw a bare TypeError from its `.filter` pre-clean
|
|
127
|
+
* BEFORE {@link resolveEffort}'s centralized non-array fallback could read it as undeclared — while
|
|
128
|
+
* the reporter happily described an effort resolution for the same config. Only the container shape
|
|
129
|
+
* is judged here; MEMBER validity stays {@link resolveEffort}'s job (its element-level sanitization
|
|
130
|
+
* is the single bad-value seat, which is also why the returned array is not member-checked — the cast
|
|
131
|
+
* is checked at runtime by every consumer's `resolveEffort` call).
|
|
132
|
+
*/
|
|
133
|
+
export declare function declaredEffortLevels(v: unknown): readonly ThinkingLevel[] | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* The effort-lane WIRE-VALUE mint — clamp ({@link resolveEffort}) + the `Model.thinkingLevelMap`
|
|
136
|
+
* translation in ONE place, consumed by both completions-family appliers (openai.ts `applyThinking`,
|
|
137
|
+
* open-responses.ts `resolveWireEffort`) AND the reporting dispatch ({@link resolveReasoning}'s
|
|
138
|
+
* arms), so the value that reaches the wire and the resolution a trace/result face claims are two
|
|
139
|
+
* reads of one computation, never parallel re-derivations. Map semantics (the field's contract): a
|
|
140
|
+
* MISSING key ⇒ provider default (the clamped tier name as-is); a STRING ⇒ that provider-specific
|
|
141
|
+
* spelling (same tier, still honored); `null` ⇒ the tier is UNSUPPORTED on this model —
|
|
142
|
+
* `wireValue: undefined`, no effort value on the wire (thinking still enables via a format's own
|
|
143
|
+
* enable key where one exists), which is exactly the tier-not-honored shape the reporter must echo.
|
|
144
|
+
*/
|
|
145
|
+
export declare function mintEffortWireValue(requested: ThinkingLevel, model: {
|
|
146
|
+
thinkingLevelMap?: Readonly<Partial<Record<ThinkingLevel, string | null>>>;
|
|
147
|
+
}, allowed: readonly ThinkingLevel[] | undefined): {
|
|
148
|
+
resolution: ReasoningResolution;
|
|
149
|
+
wireValue: string | undefined;
|
|
150
|
+
};
|
|
68
151
|
/**
|
|
69
152
|
* A {@link ReasoningResolution} enriched with the endpoint discriminant, for observability (design/96 S6).
|
|
70
153
|
* The brain consumes only the {@link ReasoningResolution} fields to shape the request; `format`/`endpoint`
|
|
@@ -77,9 +160,12 @@ export interface ResolvedReasoning extends ReasoningResolution {
|
|
|
77
160
|
/** A coarse endpoint label for the trace (`model.api` — e.g. `openai-completions`, `anthropic-messages`). */
|
|
78
161
|
endpoint: string;
|
|
79
162
|
/**
|
|
80
|
-
* Present (true) only when the
|
|
81
|
-
*
|
|
82
|
-
*
|
|
163
|
+
* Present (true) only when the request never CARRIES at all: {@link reasoningRequestCarried} false
|
|
164
|
+
* on a non-"off" request — the model declares NO reasoning capability (`Model.reasoning` falsy),
|
|
165
|
+
* OR the requested value is not a valid {@link ThinkingLevel} (an out-of-contract tier every wire
|
|
166
|
+
* applier reads as ABSENCE — the same three-conjunct gate all of them share) — or, with
|
|
167
|
+
* {@link ReasoningWireFacts} supplied, the anthropic budget path's cap-wins skip
|
|
168
|
+
* ({@link budgetCapSkipsThinking}), so NO thinking
|
|
83
169
|
* parameter reaches the wire at all — the requested tier is DROPPED entirely, not clamped or
|
|
84
170
|
* downgraded-to-binary. `effective:"off"` here states the ENGINE side of that fact (nothing was
|
|
85
171
|
* requested), NOT a measured gateway state: on the binary enable-only formats (qwen / zai /
|
|
@@ -101,7 +187,10 @@ export interface ResolvedReasoning extends ReasoningResolution {
|
|
|
101
187
|
* brains keep calling {@link resolveEffort}/{@link resolveBinary}/{@link reasoningBudgetShare} on the hot path.
|
|
102
188
|
*
|
|
103
189
|
* - Anthropic (`api === "anthropic-messages"`) → budget-based: the tier sets a budget share, so it's a real
|
|
104
|
-
* gradient (`graded:true`) and never tier-clamped (`clamped:false`); reported as `format:"budget"`.
|
|
190
|
+
* gradient (`graded:true`) and never tier-clamped (`clamped:false`); reported as `format:"budget"`. With
|
|
191
|
+
* the optional {@link ReasoningWireFacts} the arm additionally mirrors the wire's cap-wins skip
|
|
192
|
+
* ({@link budgetCapSkipsThinking}: a hard per-request output cap < 2·{@link MIN_THINKING_TOKENS} deletes
|
|
193
|
+
* the thinking block) as the drop shape — facts absent keeps the historic cap-blind gradient.
|
|
105
194
|
* - Binary enable-only formats (qwen / zai / qwen-chat-template) → `graded:false` (tier not honored).
|
|
106
195
|
* - An effort endpoint with `supportsReasoningEffort:false` → `graded:false` (enable key only, no effort tier).
|
|
107
196
|
* - Otherwise effort-based → clamp DOWN to the endpoint's `reasoningEffortLevels` (default minimal|low|medium|high).
|
|
@@ -111,11 +200,14 @@ export interface ResolvedReasoning extends ReasoningResolution {
|
|
|
111
200
|
* honored. Previously this resolver never read the map and reported such a request as exactly honored
|
|
112
201
|
* (`graded:true`, `clamped:false`) while the wire dropped the value — trace/result-face drift.
|
|
113
202
|
*
|
|
114
|
-
* - A
|
|
115
|
-
*
|
|
203
|
+
* - A request that never CARRIES ({@link reasoningRequestCarried} false on a non-"off" value: the model's
|
|
204
|
+
* `reasoning` capability flag is FALSY, or the requested value is not a valid tier — no brain emits any
|
|
205
|
+
* thinking parameter for either, whatever the format) → `effective:"off"`, `graded:false`, `clamped:true`,
|
|
116
206
|
* `dropped:true` — the loud-drop arm. Previously this resolver described the capability dispatch for such
|
|
117
207
|
* a model (a resolution the request never carried), and the runner's trace guard skipped the frame — the
|
|
118
|
-
* one arm where the request evaporates was the one arm with no disclosure
|
|
208
|
+
* one arm where the request evaporates was the one arm with no disclosure; and an out-of-contract tier
|
|
209
|
+
* was worse still — reported as a clamped-to-minimal gradient (or echoed verbatim as `effective` on the
|
|
210
|
+
* binary formats) while the wire carried nothing.
|
|
119
211
|
*
|
|
120
212
|
* `off`/falsy never enables thinking, so it resolves trivially (no clamp, graded:true) — the caller decides
|
|
121
213
|
* whether to emit at all.
|
|
@@ -125,7 +217,7 @@ export declare function resolveReasoning(requested: ThinkingLevel, model: {
|
|
|
125
217
|
reasoning?: boolean;
|
|
126
218
|
compat?: unknown;
|
|
127
219
|
thinkingLevelMap?: Readonly<Partial<Record<ThinkingLevel, string | null>>>;
|
|
128
|
-
}): ResolvedReasoning;
|
|
220
|
+
}, facts?: ReasoningWireFacts): ResolvedReasoning;
|
|
129
221
|
/**
|
|
130
222
|
* Resolve a requested intensity for an effort-based endpoint (`reasoning_effort` / `reasoning.effort`). Picks
|
|
131
223
|
* the requested tier when supported; otherwise the highest supported tier ≤ requested (clamp DOWN, never
|
package/dist/brain/reasoning.js
CHANGED
|
@@ -10,28 +10,52 @@ const RANK = {
|
|
|
10
10
|
export function isThinkingLevel(v) {
|
|
11
11
|
return typeof v === "string" && Object.prototype.hasOwnProperty.call(RANK, v);
|
|
12
12
|
}
|
|
13
|
+
export function reasoningRequestCarried(model, reasoning) {
|
|
14
|
+
return !!model.reasoning && isThinkingLevel(reasoning) && reasoning !== "off";
|
|
15
|
+
}
|
|
13
16
|
export function rankOf(level) {
|
|
14
17
|
return RANK[level];
|
|
15
18
|
}
|
|
16
19
|
export const DEFAULT_EFFORT_LEVELS = ["minimal", "low", "medium", "high"];
|
|
17
20
|
const BINARY_FORMATS = new Set(["qwen", "zai", "qwen-chat-template"]);
|
|
18
21
|
export const RESPONSES_APIS = new Set(["openai-responses", "azure-openai-responses", "openai-chatgpt-responses"]);
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
export const MIN_THINKING_TOKENS = 1024;
|
|
23
|
+
export function budgetCapSkipsThinking(outputCapTokens, hardCap) {
|
|
24
|
+
return hardCap && outputCapTokens < MIN_THINKING_TOKENS * 2;
|
|
25
|
+
}
|
|
26
|
+
export function declaredEffortLevels(v) {
|
|
27
|
+
return Array.isArray(v) && v.length > 0 ? v : undefined;
|
|
28
|
+
}
|
|
29
|
+
export function mintEffortWireValue(requested, model, allowed) {
|
|
30
|
+
const resolution = resolveEffort(requested, allowed ?? DEFAULT_EFFORT_LEVELS);
|
|
31
|
+
let wireValue = resolution.effective;
|
|
32
|
+
const mapped = model.thinkingLevelMap?.[resolution.effective];
|
|
33
|
+
if (mapped === null)
|
|
34
|
+
wireValue = undefined;
|
|
35
|
+
else if (mapped !== undefined)
|
|
36
|
+
wireValue = mapped;
|
|
37
|
+
return { resolution, wireValue };
|
|
38
|
+
}
|
|
39
|
+
export function resolveReasoning(requested, model, facts) {
|
|
40
|
+
const resolved = dispatchReasoning(requested, model, facts);
|
|
41
|
+
if (requested !== "off" && !reasoningRequestCarried(model, requested)) {
|
|
22
42
|
return { requested, effective: "off", graded: false, clamped: true, format: resolved.format, endpoint: resolved.endpoint, dropped: true };
|
|
23
43
|
}
|
|
24
44
|
return resolved;
|
|
25
45
|
}
|
|
26
|
-
function
|
|
27
|
-
return requested !== "off" && model.thinkingLevelMap?.[effective] === null;
|
|
28
|
-
}
|
|
29
|
-
function dispatchReasoning(requested, model) {
|
|
46
|
+
function dispatchReasoning(requested, model, facts) {
|
|
30
47
|
const endpoint = model.api ?? "unknown";
|
|
31
48
|
const compat = (model.compat ?? {});
|
|
32
49
|
if (model.api === "anthropic-messages") {
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
const declaredAnthropic = declaredEffortLevels(compat.effortLevels);
|
|
51
|
+
if (declaredAnthropic !== undefined) {
|
|
52
|
+
return { ...resolveEffort(requested, declaredAnthropic), format: "effort", endpoint };
|
|
53
|
+
}
|
|
54
|
+
if (requested !== "off" &&
|
|
55
|
+
compat.thinkingMode !== "adaptive" &&
|
|
56
|
+
facts !== undefined &&
|
|
57
|
+
budgetCapSkipsThinking(facts.outputCapTokens, facts.hardOutputCap)) {
|
|
58
|
+
return { requested, effective: "off", graded: false, clamped: true, format: "budget", endpoint, dropped: true };
|
|
35
59
|
}
|
|
36
60
|
return { requested, effective: requested, graded: true, clamped: false, format: "budget", endpoint };
|
|
37
61
|
}
|
|
@@ -39,11 +63,11 @@ function dispatchReasoning(requested, model) {
|
|
|
39
63
|
if (compat.supportsReasoningEffort === false) {
|
|
40
64
|
return { requested, effective: requested, graded: false, clamped: false, format: "responses", endpoint };
|
|
41
65
|
}
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
66
|
+
const responsesMint = mintEffortWireValue(requested, model, compat.reasoningEffortLevels);
|
|
67
|
+
if (requested !== "off" && responsesMint.wireValue === undefined) {
|
|
44
68
|
return { requested, effective: requested, graded: false, clamped: false, format: "responses", endpoint };
|
|
45
69
|
}
|
|
46
|
-
return { ...
|
|
70
|
+
return { ...responsesMint.resolution, format: "responses", endpoint };
|
|
47
71
|
}
|
|
48
72
|
const format = compat.thinkingFormat ?? "openai";
|
|
49
73
|
if (BINARY_FORMATS.has(format)) {
|
|
@@ -53,11 +77,11 @@ function dispatchReasoning(requested, model) {
|
|
|
53
77
|
if (!supportsEffort && format !== "openrouter") {
|
|
54
78
|
return { requested, effective: requested, graded: false, clamped: false, format, endpoint };
|
|
55
79
|
}
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
80
|
+
const mint = mintEffortWireValue(requested, model, compat.reasoningEffortLevels);
|
|
81
|
+
if (requested !== "off" && mint.wireValue === undefined) {
|
|
58
82
|
return { requested, effective: requested, graded: false, clamped: false, format, endpoint };
|
|
59
83
|
}
|
|
60
|
-
return { ...
|
|
84
|
+
return { ...mint.resolution, format, endpoint };
|
|
61
85
|
}
|
|
62
86
|
export function resolveEffort(requested, allowed = DEFAULT_EFFORT_LEVELS) {
|
|
63
87
|
const declared = Array.isArray(allowed) ? allowed.filter((lvl) => isThinkingLevel(lvl) && lvl !== "off") : [];
|
|
@@ -23,7 +23,7 @@ export declare function reservedFor(api: string): ReadonlySet<string>;
|
|
|
23
23
|
*/
|
|
24
24
|
export declare function applyExtraBody(body: Record<string, unknown>, extraBody: Record<string, unknown> | undefined, reserved: ReadonlySet<string>): Record<string, unknown>;
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
26
|
+
* Per-call auth REPLACES construction-time auth: drop every auth-bearing header
|
|
27
27
|
* (case-insensitive `authorization` / `x-api-key`) from an already-merged header bag. Called by a
|
|
28
28
|
* brain's buildRequest ONLY when a per-call `options.apiKey` is present — the brain then re-emits
|
|
29
29
|
* the credential in its own wire posture (anthropic `x-api-key`, openai `Bearer`), making the
|
|
@@ -33,6 +33,42 @@ export declare function applyExtraBody(body: Record<string, unknown>, extraBody:
|
|
|
33
33
|
* folds duplicates into one comma-joined value — broken auth both ways).
|
|
34
34
|
*/
|
|
35
35
|
export declare function stripAuthHeaders(headers: Record<string, string>): void;
|
|
36
|
+
/**
|
|
37
|
+
* #343 — the shared USER-HEADER merge layer (`model.headers` → construction `config.headers` →
|
|
38
|
+
* per-call `options.headers`, later bag wins), CASE-FOLD deduplicated: HTTP header field names are
|
|
39
|
+
* case-insensitive (RFC 9110), but the plain-object spread the three brains used
|
|
40
|
+
* (`{...model.headers, ...config.headers, ...options.headers}`) keyed by exact spelling — a
|
|
41
|
+
* `X-Tenant` in one bag and `x-tenant` in another BOTH survived and both went on the wire, where
|
|
42
|
+
* fetch's Headers folds them into one comma-joined value ("a, b"): neither writer's value, and the
|
|
43
|
+
* later layer's documented override silently defeated. Now a later bag's entry replaces an earlier
|
|
44
|
+
* case-variant; the WINNER'S spelling and value survive (a single-spelling config — every existing
|
|
45
|
+
* deployment — is byte-identical on the wire).
|
|
46
|
+
*
|
|
47
|
+
* EXEMPT: the auth carriers (`authorization` / `x-api-key`, any case) pass through with the exact
|
|
48
|
+
* legacy spread semantics (same-spelling override only, no case-fold dedup) — their case handling is
|
|
49
|
+
* {@link stripAuthHeaders}' pinned jurisdiction (the per-call-replaces flow and the
|
|
50
|
+
* header-only ANTHROPIC_AUTH_TOKEN shape, which must survive under its own capital-A spelling), and
|
|
51
|
+
* this layer must not become a second, subtly different auth authority.
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* #343 (review r4) — assign a STRUCTURAL locked header under its canonical lowercase name, deleting
|
|
55
|
+
* every case-variant spelling first. The brains hard-lock `content-type` / `anthropic-version` AFTER
|
|
56
|
+
* the user-bag merge precisely so they "can NEVER be overridden" (council design/40) — but a valid
|
|
57
|
+
* user bag carrying `Content-Type: text/plain` survived BESIDE the lowercase lock, and the platform
|
|
58
|
+
* `Headers` fold turns the pair into `text/plain, application/json` on the wire: the lock decided
|
|
59
|
+
* nothing. Auth carriers are deliberately NOT routed through here (per-call replacement + the
|
|
60
|
+
* header-only boot flow are {@link stripAuthHeaders}' pinned jurisdiction).
|
|
61
|
+
*/
|
|
62
|
+
export declare function lockHeader(headers: Record<string, string>, lowerName: string, value: string): void;
|
|
63
|
+
/**
|
|
64
|
+
* #343 (review r4) — case-fold READ-AND-CLAIM for an AUGMENTABLE structural header (`anthropic-beta`):
|
|
65
|
+
* returns the current value under whatever spelling the user bag carried and deletes that spelling,
|
|
66
|
+
* so the caller's canonical lowercase write REPLACES it instead of duplicating beside it (the
|
|
67
|
+
* read-modify-write used to key the read by the exact lowercase name and miss `Anthropic-Beta`,
|
|
68
|
+
* losing the user's betas from the merge AND double-sending the header).
|
|
69
|
+
*/
|
|
70
|
+
export declare function takeHeaderCasefold(headers: Record<string, string>, lowerName: string): string | undefined;
|
|
71
|
+
export declare function mergeHeaders(...bags: Array<Record<string, string> | undefined>): Record<string, string>;
|
|
36
72
|
/**
|
|
37
73
|
* The output-cap key(s) each lane's wire form uses. A lane's set is exactly the keys THAT lane's
|
|
38
74
|
* endpoint reads — a stray cap key belonging to another wire form is inert there and must not be
|
|
@@ -54,13 +54,51 @@ export function applyExtraBody(body, extraBody, reserved) {
|
|
|
54
54
|
}
|
|
55
55
|
return { ...passthrough, ...body };
|
|
56
56
|
}
|
|
57
|
+
const AUTH_CARRIER_NAMES = new Set(["authorization", "x-api-key"]);
|
|
57
58
|
export function stripAuthHeaders(headers) {
|
|
58
59
|
for (const k of Object.keys(headers)) {
|
|
59
|
-
|
|
60
|
-
if (lower === "authorization" || lower === "x-api-key")
|
|
60
|
+
if (AUTH_CARRIER_NAMES.has(k.toLowerCase()))
|
|
61
61
|
delete headers[k];
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
export function lockHeader(headers, lowerName, value) {
|
|
65
|
+
for (const k of Object.keys(headers)) {
|
|
66
|
+
if (k !== lowerName && k.toLowerCase() === lowerName)
|
|
67
|
+
delete headers[k];
|
|
68
|
+
}
|
|
69
|
+
headers[lowerName] = value;
|
|
70
|
+
}
|
|
71
|
+
export function takeHeaderCasefold(headers, lowerName) {
|
|
72
|
+
for (const k of Object.keys(headers)) {
|
|
73
|
+
if (k.toLowerCase() === lowerName) {
|
|
74
|
+
const v = headers[k];
|
|
75
|
+
delete headers[k];
|
|
76
|
+
return v;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
export function mergeHeaders(...bags) {
|
|
82
|
+
const out = {};
|
|
83
|
+
const spellingByFold = new Map();
|
|
84
|
+
for (const bag of bags) {
|
|
85
|
+
if (!bag)
|
|
86
|
+
continue;
|
|
87
|
+
for (const [name, value] of Object.entries(bag)) {
|
|
88
|
+
const fold = name.toLowerCase();
|
|
89
|
+
if (AUTH_CARRIER_NAMES.has(fold)) {
|
|
90
|
+
out[name] = value;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const prior = spellingByFold.get(fold);
|
|
94
|
+
if (prior !== undefined && prior !== name)
|
|
95
|
+
delete out[prior];
|
|
96
|
+
spellingByFold.set(fold, name);
|
|
97
|
+
out[name] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
64
102
|
export const OUTPUT_CAP_KEYS = {
|
|
65
103
|
openai: ["max_tokens", "max_completion_tokens"],
|
|
66
104
|
anthropic: ["max_tokens"],
|
|
@@ -49,6 +49,14 @@ function excerpt(text, cap) {
|
|
|
49
49
|
return text;
|
|
50
50
|
return `${text.slice(0, cap)} [… ${text.length - cap} chars truncated]`;
|
|
51
51
|
}
|
|
52
|
+
function excerptTailInclusive(text, cap) {
|
|
53
|
+
if (text.length <= cap)
|
|
54
|
+
return text;
|
|
55
|
+
const half = Math.floor(cap / 2);
|
|
56
|
+
const marker = ` [… ${text.length - 2 * half} chars not shown …] `;
|
|
57
|
+
const out = `${text.slice(0, half)}${marker}${text.slice(text.length - half)}`;
|
|
58
|
+
return out.length >= text.length ? text : out;
|
|
59
|
+
}
|
|
52
60
|
function renderEntry(m, cap) {
|
|
53
61
|
if (m.role === "user") {
|
|
54
62
|
const raw = typeof m.content === "string" ? m.content : m.content.map((c) => (c.type === "text" ? c.text : `[${c.type}]`)).join("\n");
|
|
@@ -102,5 +110,5 @@ export function renderAutoModeWindow(messages, options) {
|
|
|
102
110
|
export function renderAutoModeAction(input) {
|
|
103
111
|
const ask = input.askMessage ? `\npermission gate: ${input.askMessage}` : "";
|
|
104
112
|
return (`\n## New action to classify (the agent's most recent action — evaluate THIS)\n\n` +
|
|
105
|
-
`[tool_call] ${input.req.toolName} ${
|
|
113
|
+
`[tool_call] ${input.req.toolName} ${excerptTailInclusive(JSON.stringify(input.req.args ?? {}), 48_000)}${ask}\n`);
|
|
106
114
|
}
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -198,6 +198,13 @@ export interface PermissionDeniedPayload {
|
|
|
198
198
|
reason: string;
|
|
199
199
|
/** Which gate source produced the deny (our `decision_reason_type` analog). */
|
|
200
200
|
source: PermissionDeniedSource;
|
|
201
|
+
/** The ask resolver's own deny-arm classification, carried BESIDE `source` (two different
|
|
202
|
+
* questions: `source` names which LAYER raised the gate; this names HOW the ask resolution
|
|
203
|
+
* refused — a person's no vs a timeout vs headless vs an approver contract violation …). Present
|
|
204
|
+
* only on a deny that came through an ask resolution AND whose word passed the closed-vocabulary
|
|
205
|
+
* screen; a policy's direct deny, a hook deny, and the crash/plan-mode/compliance emissions carry
|
|
206
|
+
* none. See {@link import("./tool-policy.js").AskDenyResolution}. */
|
|
207
|
+
resolution?: import("./tool-policy.js").AskDenyResolution;
|
|
201
208
|
}
|
|
202
209
|
/**
|
|
203
210
|
* 1.256 复审 MED-1 — observe-only payload isolation for {@link Hooks.permissionDenied}: clone the tool
|
|
@@ -606,6 +613,14 @@ export interface ToolGateResult {
|
|
|
606
613
|
* and no post-tool hook can write.
|
|
607
614
|
*/
|
|
608
615
|
settledBy?: import("./tool-policy.js").ApprovalSettledBy;
|
|
616
|
+
/**
|
|
617
|
+
* The ask resolver's deny-arm classification (see {@link import("./tool-policy.js").AskDenyResolution}),
|
|
618
|
+
* present only on a BLOCK whose deny came through an ask resolution and passed the closed-vocabulary
|
|
619
|
+
* screen at the deny exit (a self-declared word on a policy's own deny is dropped there, never
|
|
620
|
+
* forwarded). Rides beside {@link settledBy} to the caller's per-call sideband and the call's
|
|
621
|
+
* `tool_end` frame — the machine-readable "why was this refused" a consumer classifies on.
|
|
622
|
+
*/
|
|
623
|
+
resolution?: import("./tool-policy.js").AskDenyResolution;
|
|
609
624
|
/**
|
|
610
625
|
* design/252 G-7 — WHOSE settlement that was: the identifier the approval channel reported, carried
|
|
611
626
|
* out verbatim beside {@link settledBy}. This layer authenticates nothing and compares nothing; the
|
|
@@ -915,7 +930,10 @@ export interface ToolGateInput {
|
|
|
915
930
|
* probe cannot widen anything through this member.
|
|
916
931
|
*/
|
|
917
932
|
reversibilityProbe?: (args: unknown) => import("./types.js").ReversibilityVerdict | Promise<import("./types.js").ReversibilityVerdict>;
|
|
918
|
-
/** design/77 §4: deadline (ms) for {@link reversibilityProbe}; on timeout the gate fails closed to `ask`.
|
|
933
|
+
/** design/77 §4: deadline (ms) for {@link reversibilityProbe}; on timeout the gate fails closed to `ask`.
|
|
934
|
+
* ABSENT ⇒ a bounded default applies (30s — the probe wait is never unbounded, even with no
|
|
935
|
+
* {@link abortSignal}); a non-finite/negative value is refused loudly (via {@link onHookError}) to
|
|
936
|
+
* that same default, never silently reinterpreted. `0` is honored as written (immediate deadline). */
|
|
919
937
|
approvalTimeoutMs?: number;
|
|
920
938
|
/** design/77 §4: the task abort signal — bounds {@link reversibilityProbe} by the task's real deadline
|
|
921
939
|
* (timeout/cancel) in addition to {@link approvalTimeoutMs}; an abort while probing fails closed to `ask`. */
|
|
@@ -937,6 +955,11 @@ export interface ToolGateInput {
|
|
|
937
955
|
* this one carries the exception object itself to whoever runs the deployment, because a crashing hook is
|
|
938
956
|
* a bug someone has to fix and the model-facing summary is bounded/sanitized. Never affects the outcome
|
|
939
957
|
* (a throwing sink is swallowed).
|
|
958
|
+
*
|
|
959
|
+
* ALSO fired for a {@link reversibilityProbe} that threw or timed out (same species — a
|
|
960
|
+
* deployment-supplied callback failing while the gate holds the fail-closed line) and for a
|
|
961
|
+
* malformed {@link approvalTimeoutMs} refused to the bounded default. A task-abort rejection
|
|
962
|
+
* mid-probe is NOT reported (normal cancellation, not a defect).
|
|
940
963
|
*/
|
|
941
964
|
onHookError?: (err: unknown) => void;
|
|
942
965
|
/**
|
package/dist/core/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decisionText, describeThrown, refuseOutOfContractDecision } from "./tool-policy.js";
|
|
1
|
+
import { decisionText, describeThrown, isAskDenyResolution, refuseOutOfContractDecision } from "./tool-policy.js";
|
|
2
2
|
import { brandPolicyAskClass } from "./ask-class.js";
|
|
3
3
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
4
4
|
import { mintSystemReminder } from "./reminder-mint.js";
|
|
@@ -269,6 +269,7 @@ function withProbeTimeout(p, ms, signal) {
|
|
|
269
269
|
p.then((v) => done(resolve, v), (err) => done(reject, err));
|
|
270
270
|
});
|
|
271
271
|
}
|
|
272
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
|
|
272
273
|
export function persistedRuleMandateOf(marks) {
|
|
273
274
|
return marks.egress === true
|
|
274
275
|
? "tool_marks"
|
|
@@ -293,6 +294,7 @@ export async function runToolGate(input) {
|
|
|
293
294
|
const preToolContext = [];
|
|
294
295
|
let hookAsk;
|
|
295
296
|
let parkFailed;
|
|
297
|
+
let askDenyResolution;
|
|
296
298
|
const notifier = createSafeNotifier(input.onNotifyError !== undefined ? { onError: input.onNotifyError } : undefined);
|
|
297
299
|
if (preToolUse) {
|
|
298
300
|
let r;
|
|
@@ -373,8 +375,20 @@ export async function runToolGate(input) {
|
|
|
373
375
|
if (input.irreversibility === "maybe" && input.reversibilityProbe) {
|
|
374
376
|
let reversible = false;
|
|
375
377
|
const probeArgs = policyRewrite !== undefined ? policyRewrite : currentInput;
|
|
378
|
+
const suppliedProbeMs = input.approvalTimeoutMs;
|
|
379
|
+
let probeTimeoutMs;
|
|
380
|
+
if (suppliedProbeMs === undefined) {
|
|
381
|
+
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
|
|
382
|
+
}
|
|
383
|
+
else if (Number.isFinite(suppliedProbeMs) && suppliedProbeMs >= 0 && suppliedProbeMs <= 2_147_483_647) {
|
|
384
|
+
probeTimeoutMs = suppliedProbeMs;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
|
|
388
|
+
traceHookCrash(input, new Error(`approvalTimeoutMs must be a non-negative finite number no greater than 2147483647 (got ${String(suppliedProbeMs)}) — the reversibilityProbe deadline falls back to the ${DEFAULT_PROBE_TIMEOUT_MS}ms default`), notifier);
|
|
389
|
+
}
|
|
376
390
|
try {
|
|
377
|
-
const verdict = await withProbeTimeout(Promise.resolve(input.reversibilityProbe(probeArgs)),
|
|
391
|
+
const verdict = await withProbeTimeout(Promise.resolve(input.reversibilityProbe(probeArgs)), probeTimeoutMs, input.abortSignal);
|
|
378
392
|
reversible = verdict?.reversible === true;
|
|
379
393
|
if (!reversible) {
|
|
380
394
|
const raw = verdict?.reason;
|
|
@@ -383,8 +397,10 @@ export async function runToolGate(input) {
|
|
|
383
397
|
probeCause = normalizeProbeCause(verdict?.cause);
|
|
384
398
|
}
|
|
385
399
|
}
|
|
386
|
-
catch {
|
|
400
|
+
catch (err) {
|
|
387
401
|
reversible = false;
|
|
402
|
+
if (input.abortSignal?.aborted !== true)
|
|
403
|
+
traceHookCrash(input, err, notifier);
|
|
388
404
|
}
|
|
389
405
|
tighten = !reversible;
|
|
390
406
|
}
|
|
@@ -671,6 +687,8 @@ export async function runToolGate(input) {
|
|
|
671
687
|
const resolved = await resolveAsk(decision, req);
|
|
672
688
|
if (resolved.action !== "ask" && resolved.approver !== undefined)
|
|
673
689
|
resolvedApprover = resolved.approver;
|
|
690
|
+
if (resolved.action === "deny" && isAskDenyResolution(resolved.resolution))
|
|
691
|
+
askDenyResolution = resolved.resolution;
|
|
674
692
|
decision = resolved;
|
|
675
693
|
if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
|
|
676
694
|
const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeCause : undefined);
|
|
@@ -771,6 +789,8 @@ export async function runToolGate(input) {
|
|
|
771
789
|
const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
|
|
772
790
|
resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
|
|
773
791
|
if (rr.action !== "allow") {
|
|
792
|
+
if (rr.action === "deny" && isAskDenyResolution(rr.resolution))
|
|
793
|
+
askDenyResolution = rr.resolution;
|
|
774
794
|
editDenied = rr;
|
|
775
795
|
if (!orgRaisedThisRound)
|
|
776
796
|
denySource = "policy";
|
|
@@ -807,8 +827,9 @@ export async function runToolGate(input) {
|
|
|
807
827
|
if (decision.updatedInput !== undefined) {
|
|
808
828
|
currentInput = decision.updatedInput;
|
|
809
829
|
}
|
|
830
|
+
const denyResolution = askDenyResolution;
|
|
810
831
|
if (input.permissionDenied) {
|
|
811
|
-
await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
|
|
832
|
+
await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
|
|
812
833
|
}
|
|
813
834
|
const denySettledBy = decision.settledBy;
|
|
814
835
|
const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
|
|
@@ -816,6 +837,7 @@ export async function runToolGate(input) {
|
|
|
816
837
|
block: true,
|
|
817
838
|
reason: formatHookFeedback(denyReason, input.reminderMark),
|
|
818
839
|
...(denySettledBy !== undefined ? { settledBy: denySettledBy } : {}),
|
|
840
|
+
...(denyResolution !== undefined ? { resolution: denyResolution } : {}),
|
|
819
841
|
...(denyApprover !== undefined ? { approver: denyApprover } : {}),
|
|
820
842
|
preToolContext,
|
|
821
843
|
};
|
package/dist/core/mcp.d.ts
CHANGED
|
@@ -193,7 +193,10 @@ export interface McpRefreshResult {
|
|
|
193
193
|
* DOMAIN for the swap (review F4: a prefix domain is self-healing and decoupled from the diff
|
|
194
194
|
* baseline, which advances even when a consumer skips a swap). Present on every entry. */
|
|
195
195
|
prefix: string;
|
|
196
|
-
|
|
196
|
+
/** `"revoked"` (design/338): the host ledger marks this server revoked — the refresh did NOT
|
|
197
|
+
* contact it (no tools/list round trip; the engine must not hand a severed server a request
|
|
198
|
+
* channel or splice its post-revocation text into the model catalog). */
|
|
199
|
+
status: "refreshed" | "not_connected" | "failed" | "revoked";
|
|
197
200
|
toolCount: number;
|
|
198
201
|
added: string[];
|
|
199
202
|
removed: string[];
|
|
@@ -467,6 +470,9 @@ export declare function materializeMcpTools(specs: McpServerSpec[], principal?:
|
|
|
467
470
|
reminderDisclosure?: {
|
|
468
471
|
reminderMark?: string;
|
|
469
472
|
counts?: ReminderDisclosureCounts;
|
|
473
|
+
}, mcpRevocations?: {
|
|
474
|
+
isRevoked(serverName: string): boolean;
|
|
475
|
+
onProbeFailure?: (error: unknown) => void;
|
|
470
476
|
}): Promise<MaterializedMcp>;
|
|
471
477
|
/**
|
|
472
478
|
* Fold the caller's AUTHORITATIVE per-tool override (design F: caller = trust root) over the server-hint axis.
|