@sema-agent/core 5.55.0 → 5.57.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 (81) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/agents/send-message-tool.d.ts +11 -0
  3. package/dist/agents/send-message-tool.js +81 -13
  4. package/dist/agents/subagent.js +250 -89
  5. package/dist/agents/team.d.ts +10 -1
  6. package/dist/agents/team.js +1 -0
  7. package/dist/brain/anthropic.js +15 -5
  8. package/dist/brain/circuit-breaker.js +2 -1
  9. package/dist/brain/degrading.js +4 -1
  10. package/dist/brain/failover.js +16 -1
  11. package/dist/brain/open-responses.js +15 -5
  12. package/dist/brain/openai.js +16 -5
  13. package/dist/brain/request-params.d.ts +30 -27
  14. package/dist/brain/request-params.js +1 -7
  15. package/dist/brain/route-adjudicator.d.ts +190 -0
  16. package/dist/brain/route-adjudicator.js +189 -0
  17. package/dist/brain/route-conformance.d.ts +55 -0
  18. package/dist/brain/route-conformance.js +136 -0
  19. package/dist/brain/routing.js +8 -3
  20. package/dist/core/auto-compaction.d.ts +17 -4
  21. package/dist/core/auto-compaction.js +3 -0
  22. package/dist/core/context-edit.d.ts +55 -6
  23. package/dist/core/context-edit.js +12 -1
  24. package/dist/core/hooks.d.ts +293 -11
  25. package/dist/core/hooks.js +158 -11
  26. package/dist/core/human-input-projection.d.ts +20 -2
  27. package/dist/core/human-input-projection.js +9 -0
  28. package/dist/core/mcp.js +4 -4
  29. package/dist/core/memory-engine/engine.d.ts +15 -5
  30. package/dist/core/memory-engine/engine.js +3 -1
  31. package/dist/core/permission-rule-consent.d.ts +45 -0
  32. package/dist/core/permission-rule-consent.js +40 -11
  33. package/dist/core/permission-rule-model.d.ts +110 -75
  34. package/dist/core/permission-rule-model.js +61 -28
  35. package/dist/core/permission-rules.d.ts +23 -15
  36. package/dist/core/permission-rules.js +40 -31
  37. package/dist/core/runner/prepare-task.d.ts +8 -0
  38. package/dist/core/runner/prepare-task.js +66 -26
  39. package/dist/core/runner/runtask.d.ts +4 -1
  40. package/dist/core/runner/runtask.js +206 -21
  41. package/dist/core/runner/session-rule-policy.js +5 -5
  42. package/dist/core/scheduler.d.ts +5 -0
  43. package/dist/core/session-reconcile.d.ts +32 -0
  44. package/dist/core/session-reconcile.js +15 -0
  45. package/dist/core/side-query.d.ts +12 -5
  46. package/dist/core/task-notification.d.ts +34 -7
  47. package/dist/core/task-notification.js +11 -1
  48. package/dist/core/task-registry-agent.d.ts +20 -3
  49. package/dist/core/task-registry-agent.js +31 -2
  50. package/dist/core/tool-policy.d.ts +14 -9
  51. package/dist/core/tool-policy.js +27 -22
  52. package/dist/core/types.d.ts +69 -11
  53. package/dist/core/untrusted-text.js +8 -0
  54. package/dist/engine/compaction/compaction.d.ts +77 -7
  55. package/dist/engine/compaction/compaction.js +98 -9
  56. package/dist/engine/compaction/utils.d.ts +4 -0
  57. package/dist/engine/compaction/utils.js +6 -0
  58. package/dist/engine/harness/agent-harness.d.ts +84 -0
  59. package/dist/engine/harness/agent-harness.js +114 -13
  60. package/dist/engine/harness/messages.d.ts +4 -2
  61. package/dist/engine/harness/messages.js +7 -2
  62. package/dist/engine/harness/types.d.ts +16 -6
  63. package/dist/engine/llm/types.d.ts +65 -0
  64. package/dist/engine/loop/types.d.ts +7 -0
  65. package/dist/engine/session/import-validate.js +10 -0
  66. package/dist/engine/session/session.js +2 -2
  67. package/dist/index.d.ts +4 -1
  68. package/dist/index.js +3 -1
  69. package/dist/internal/llm.d.ts +1 -1
  70. package/dist/orchestration/run-spec.js +8 -1
  71. package/dist/prompts/default.d.ts +12 -6
  72. package/dist/prompts/default.js +2 -0
  73. package/dist/scenarios/scenario-registry.d.ts +5 -1
  74. package/dist/scenarios/scenario-registry.js +4 -2
  75. package/dist/tools/fs/index.js +8 -1
  76. package/dist/tools/scheduler-tools.js +28 -6
  77. package/dist/tools/web.d.ts +15 -0
  78. package/dist/tools/web.js +8 -2
  79. package/dist/tools/worktree.js +2 -2
  80. package/package.json +1 -1
  81. package/test/export-surface.snapshot.json +19 -1
@@ -0,0 +1,136 @@
1
+ export const ROUTE_ADJUDICATION_CONFORMANCE_CORPUS = [
2
+ {
3
+ name: "per-model-on-root",
4
+ intent: "a per-model credential is paired by construction, on the deployment root",
5
+ model: { id: "m-main", baseUrl: "https://gw.example.com" },
6
+ credential: { source: "per-model", apiKey: "k-main" },
7
+ config: { baseUrl: "https://gw.example.com" },
8
+ expect: { ok: true, posture: "per-model" },
9
+ },
10
+ {
11
+ name: "per-model-off-root",
12
+ intent: "a per-model credential is paired by construction even off the deployment root (the resolver was asked about THIS entry)",
13
+ model: { id: "m-third", baseUrl: "https://third-party.example.net/v1" },
14
+ credential: { source: "per-model", apiKey: "k-third" },
15
+ config: { baseUrl: "https://gw.example.com" },
16
+ expect: { ok: true, posture: "per-model" },
17
+ },
18
+ {
19
+ name: "per-model-headers-borne",
20
+ intent: "an entry-declared auth header is a per-model credential (headers-borne)",
21
+ model: { id: "m-hdr", baseUrl: "https://third-party.example.net" },
22
+ credential: { source: "per-model", headersBorne: true, carrierBag: "model" },
23
+ config: { baseUrl: "https://gw.example.com" },
24
+ expect: { ok: true, posture: "per-model" },
25
+ },
26
+ {
27
+ name: "per-model-empty-string-key",
28
+ intent: "an empty-string per-model key is a PRESENT credential (fails closed at emit, never falls to the deployment key)",
29
+ model: { id: "m-degraded", baseUrl: "https://third-party.example.net" },
30
+ credential: { source: "per-model", apiKey: "" },
31
+ config: { baseUrl: "https://gw.example.com" },
32
+ expect: { ok: true, posture: "per-model" },
33
+ },
34
+ {
35
+ name: "deployment-paired",
36
+ intent: "the main-gateway pass: entry URL === declared config root, deployment credential allowed",
37
+ model: { id: "m-main", baseUrl: "https://gw.example.com" },
38
+ credential: { source: "deployment-config", apiKey: "k-gw" },
39
+ config: { baseUrl: "https://gw.example.com" },
40
+ expect: { ok: true, posture: "paired" },
41
+ },
42
+ {
43
+ name: "deployment-paired-empty-entry",
44
+ intent: "an entry with an empty baseUrl targets the config root — deployment credential paired",
45
+ model: { id: "m-implicit", baseUrl: "" },
46
+ credential: { source: "deployment-config", apiKey: "k-gw" },
47
+ config: { baseUrl: "https://gw.example.com" },
48
+ expect: { ok: true, posture: "paired" },
49
+ },
50
+ {
51
+ name: "deployment-paired-slash-normalized",
52
+ intent: "trailing slashes do not break the pairing (both halves normalize before compare)",
53
+ model: { id: "m-main", baseUrl: "https://gw.example.com/" },
54
+ credential: { source: "deployment-config", apiKey: "k-gw" },
55
+ config: { baseUrl: "https://gw.example.com" },
56
+ expect: { ok: true, posture: "paired" },
57
+ },
58
+ {
59
+ name: "deployment-mismatch",
60
+ intent: "THE torn pair: deployment credential + entry URL off the declared root = refusal, not a silently-followed key",
61
+ model: { id: "m-third", baseUrl: "https://third-party.example.net/v1" },
62
+ credential: { source: "deployment-config", apiKey: "k-gw" },
63
+ config: { baseUrl: "https://gw.example.com" },
64
+ expect: { ok: false, code: "route.credential_mismatch" },
65
+ leg: "named",
66
+ },
67
+ {
68
+ name: "deployment-mismatch-path-differs",
69
+ intent: "a differing path is a differing root (no prefix leniency — the credential goes to the exact declared root only)",
70
+ model: { id: "m-path", baseUrl: "https://gw.example.com/other" },
71
+ credential: { source: "deployment-config", apiKey: "k-gw" },
72
+ config: { baseUrl: "https://gw.example.com/v1" },
73
+ expect: { ok: false, code: "route.credential_mismatch" },
74
+ leg: "named",
75
+ },
76
+ {
77
+ name: "deployment-mismatch-derived-leg",
78
+ intent: "the same torn pair on a DERIVED model: same verdict; the engine disposes of it as fallback-to-primary + notice",
79
+ model: { id: "m-summarize", baseUrl: "https://third-party.example.net" },
80
+ credential: { source: "deployment-config", apiKey: "k-gw" },
81
+ config: { baseUrl: "https://gw.example.com" },
82
+ expect: { ok: false, code: "route.credential_mismatch" },
83
+ leg: "derived",
84
+ },
85
+ {
86
+ name: "deployment-headers-borne-mismatch",
87
+ intent: "a deployment auth HEADER is the same fallback arm: off the declared root it is refused, not sent",
88
+ model: { id: "m-third", baseUrl: "https://third-party.example.net" },
89
+ credential: { source: "deployment-config", headersBorne: true, carrierBag: "config" },
90
+ config: { baseUrl: "https://gw.example.com" },
91
+ expect: { ok: false, code: "route.credential_mismatch" },
92
+ leg: "named",
93
+ },
94
+ {
95
+ name: "deployment-unpinned",
96
+ intent: "the quick-start posture: config key with NO declared config root stays allowed (single-endpoint form: key on config, URL on the model) — surfaced as unpinned, not refused",
97
+ model: { id: "m-quickstart", baseUrl: "https://api.example.com/v1" },
98
+ credential: { source: "deployment-config", apiKey: "k-quickstart" },
99
+ config: {},
100
+ expect: { ok: true, posture: "unpinned" },
101
+ },
102
+ {
103
+ name: "absent-keyless-on-root",
104
+ intent: "a keyless deployment on its own root has nothing to protect — allowed",
105
+ model: { id: "m-local", baseUrl: "http://127.0.0.1:8000/v1" },
106
+ credential: { source: "absent" },
107
+ config: { baseUrl: "http://127.0.0.1:8000/v1" },
108
+ expect: { ok: true, posture: "keyless" },
109
+ },
110
+ {
111
+ name: "absent-keyless-undeclared",
112
+ intent: "keyless with no declared config root (URL only on the model) — allowed",
113
+ model: { id: "m-local", baseUrl: "http://127.0.0.1:8000/v1" },
114
+ credential: { source: "absent" },
115
+ config: {},
116
+ expect: { ok: true, posture: "keyless" },
117
+ },
118
+ {
119
+ name: "absent-missing-off-root",
120
+ intent: "an entry declaring its OWN URL away from a DECLARED deployment root with no credential anywhere is a configuration hole — loud refusal beats an unauthenticated request to a third-party host",
121
+ model: { id: "m-third", baseUrl: "https://third-party.example.net" },
122
+ credential: { source: "absent" },
123
+ config: { baseUrl: "https://gw.example.com" },
124
+ expect: { ok: false, code: "route.credential_missing" },
125
+ leg: "named",
126
+ },
127
+ {
128
+ name: "absent-missing-derived-leg",
129
+ intent: "the same hole on a DERIVED model: same verdict; engine disposition is fallback-to-primary + notice",
130
+ model: { id: "m-summarize", baseUrl: "https://third-party.example.net" },
131
+ credential: { source: "absent" },
132
+ config: { baseUrl: "https://gw.example.com" },
133
+ expect: { ok: false, code: "route.credential_missing" },
134
+ leg: "derived",
135
+ },
136
+ ];
@@ -15,13 +15,17 @@ function errorMessage(model, text) {
15
15
  }
16
16
  export function createRoutingBrain(routes, opts = {}) {
17
17
  const by = opts.by ?? "provider";
18
- const stream = (model, context, options) => {
18
+ const resolveTarget = (model) => {
19
19
  const key = by === "api" ? model.api : model.provider;
20
20
  const routed = Object.hasOwn(routes, key) ? routes[key] : undefined;
21
21
  const matched = routed !== null && typeof routed === "object" && typeof routed.stream === "function"
22
22
  ? routed
23
23
  : undefined;
24
- const target = matched ?? opts.fallback;
24
+ return matched ?? opts.fallback;
25
+ };
26
+ const stream = (model, context, options) => {
27
+ const key = by === "api" ? model.api : model.provider;
28
+ const target = resolveTarget(model);
25
29
  if (!target) {
26
30
  const out = createAssistantMessageEventStream();
27
31
  out.push({
@@ -33,5 +37,6 @@ export function createRoutingBrain(routes, opts = {}) {
33
37
  }
34
38
  return target.stream(model, context, options);
35
39
  };
36
- return { stream };
40
+ const adjudicateRoute = (model, perModelAuth) => resolveTarget(model)?.adjudicateRoute?.(model, perModelAuth);
41
+ return { stream, adjudicateRoute };
37
42
  }
@@ -383,7 +383,12 @@ export interface MaybeCompactOptions {
383
383
  * compaction is FORCED through the real `generateSummary` LLM path even if the provider would hit — this
384
384
  * bounds summary drift from indefinite reuse. The caller owns the counter via {@link onCompaction}
385
385
  * (`reused`) and feeds it back via this option; core treats `summaryProvider` as if absent for that one
386
- * boundary when `consecutiveProviderReuse >= maxConsecutiveProviderReuse`. Default `3` (CC parity).
386
+ * boundary when `consecutiveProviderReuse >= maxConsecutiveProviderReuse`. Default `3`, a
387
+ * SEMA choice — the earlier "(CC parity)" label was unsupported and is withdrawn (anchoring
388
+ * re-check 2026-08-23: CC 2.1.223 has no external summary provider and no reuse concept at all;
389
+ * its three 3s are the PTL retry cap `b$d`, the consecutive-compaction-failure breaker `C$d` and
390
+ * the rapid-refill trip `Q3u`, none of them this). 3 is picked for the same reason those are: a
391
+ * small bound on how long a degraded path may keep answering before the real one is forced.
387
392
  * `0`/undefined with no `consecutiveProviderReuse` = provider always consulted (no forced refresh).
388
393
  */
389
394
  maxConsecutiveProviderReuse?: number;
@@ -618,9 +623,17 @@ export declare function maybeCompact(opts: MaybeCompactOptions): Promise<{
618
623
  * (clamped ≥0, so ≤0 means "freed nothing") — orthogonal to rule 2/3's threshold test, which
619
624
  * misses this shape because the bloated post (44609) can still sit UNDER the threshold (44800),
620
625
  * so rule 3 alone would re-enable the force and repeat the negative-yield pass every boundary.
621
- * SEMA-ONLY DEFENSE, not a CC port: CC's full compact keeps tail 0 and monotonically shrinks
622
- * (capture §A4), and CC has no request-layer trim, so this regime cannot exist there it is a
623
- * derived interaction of our trim seam × keep-tail × attachment mass. Scoped to trim-forced
626
+ * SEMA-ONLY DEFENSE, not a CC port but the ORIGINAL justification for that label was wrong
627
+ * on both of its premises, and the corrected pair still supports it. (a) "CC's full compact
628
+ * keeps tail 0 and monotonically shrinks" describes the `GNo` branch, which a LOCAL threshold
629
+ * compaction never reaches (CC 2.1.223 `y9s` :432704 routes local passes to the reactive
630
+ * pipeline, which preserves the last group verbatim — `YMo` :399971, `s = 1` :399982); CC's
631
+ * default posture therefore has a keep-tail, exactly as ours does. (b) "CC has no
632
+ * request-layer trim" holds only for the MAIN conversation request: CC does own a
633
+ * keep-the-newest-groups-that-fit primitive (`UPb` :645223), whose only caller is the
634
+ * prompt-hook evaluator's own transcript window, not a main-lane request. What actually makes
635
+ * this regime ours is the third factor — the trim seam feeding a deceived usage anchor, and
636
+ * attachment mass re-entering after the fold — not the absence of a keep-tail. Scoped to trim-forced
624
637
  * passes only (`trimForced`), so Seam C reused-summary landings (freed≈0 by design) on the
625
638
  * natural/manual paths never trip it. Release path unchanged: backoff gates only the FORCE, a
626
639
  * later natural landing that posts under threshold (rule 3, non-trim-forced) turns it back off.
@@ -196,6 +196,9 @@ export async function maybeCompact(opts) {
196
196
  if (prep.value.elidedMessages !== undefined && prep.value.elidedMessages > 0) {
197
197
  details.elidedMessages = prep.value.elidedMessages;
198
198
  }
199
+ if (prep.value.carriedUnsummarizedMessages !== undefined && prep.value.carriedUnsummarizedMessages > 0) {
200
+ details.unsummarizedMessages = prep.value.carriedUnsummarizedMessages;
201
+ }
199
202
  }
200
203
  else {
201
204
  let summaryModel = opts.compactionModel ?? opts.model;
@@ -14,11 +14,18 @@ export declare const EDIT_FRACTION = 0.7;
14
14
  * effectiveWindow = autocompactWindow − min(maxOutputTokens, 20000) (CC `Nye`, cap `uMd`)
15
15
  * trigger = effectiveWindow − 13000 (CC `dSo`, buffer `rMd`)
16
16
  * i.e. trigger = W − 33000 for every model whose max output is ≥ 20k: a 200k window triggers at
17
- * 167000 (83.5% of nominal), a 1M window at 967000. (CC additionally ships a sonnet-5-only 1M
18
- * autocompact-window special of 967000 via dynamic config, making ITS 1M trigger 934000; that is a
19
- * per-model config delivery, not geometry an embedder reproduces it EXACTLY via
20
- * `model.autoCompactTokens = 967000`, which lowers only this trigger-side window while the guard
21
- * and physical request budgeting stay on the 1M window CC's dual-window shape.) We take the
17
+ * 167000 (83.5% of nominal), a 1M window at 967000.
18
+ *
19
+ * 1M DIVERGENCE, stated as fact rather than as an equivalence (anchoring re-check 2026-08-23): CC
20
+ * carries a MODEL-DEFAULT autocompact-window table (2.1.223 `j3u` :242918 `claude-sonnet-5`
21
+ * default 967000, and since 223 a surface dimension: `remote_cowork` / `local-agent` 500000), so a
22
+ * 1M sonnet-5 gets 967000 as its window and 934000 as its trigger WITHOUT anyone configuring
23
+ * anything. We ship no such table: a model declaring only `contextWindow: 1e6` triggers at 967000
24
+ * here, 33000 LATER than CC. `model.autoCompactTokens = 967000` reproduces CC's number — but that
25
+ * is an EMBEDDER action against CC's product default, not the same posture, and the choice not to
26
+ * follow 934000 is deliberate (design/146 §1.3 argues against living on that thin a cushion). The
27
+ * knob itself is real and unchanged: `autoCompactTokens` lowers only this trigger-side window while
28
+ * the guard and physical request budgeting stay on the 1M window — CC's dual-window shape. We take the
22
29
  * 20000 cap branch unconditionally (flat 33000), i.e. the `min(maxOutputTokens, 20000)` branch is
23
30
  * deliberately NOT ported: the deduction is exact parity only for models with max output ≥ 20k. A
24
31
  * model with a smaller max output would deduct less in CC (trigger LATER); we deduct the full
@@ -51,11 +58,53 @@ export declare function contextEditFrontier(window: number): number;
51
58
  * PowerShell is kept for CC fidelity even though sema does not mount it. Overridable per call via
52
59
  * {@link ContextEditOptions.compactableTools} for custom-tool-heavy embedders.
53
60
  */
61
+ /**
62
+ * How many most-recent CANDIDATE tool results keep their content when the stale-result pass runs.
63
+ *
64
+ * 3, UNCHANGED — and the fact that CC's corresponding number is 5 (2.1.223 `uAp`, and the same value
65
+ * on CC 88's `timeBasedMCConfig`, so it is stable across both corpus generations) is deliberately NOT
66
+ * adopted here. Two reasons, in order of weight:
67
+ *
68
+ * 1. MEASURED: widening the window removes this pass's only lever in the shape where a terminal
69
+ * parallel batch holds exactly as many large results as the window keeps. Probed on a 200k model
70
+ * with a 140k usage anchor and five 36k-char Bash results: at 3 the pass clears two and the
71
+ * request lands at ~167k, UNDER the 177k request guard; at 5 nothing is clearable, the request
72
+ * stays at 185k, and the guard cannot recover it either — `trimToBudget` must turn-align back to
73
+ * the emitting assistant, so the whole batch is retained. The window is count-based on both
74
+ * sides (CC's `slice(-keepRecent)` too), so this ceiling exists at every value; 5 simply widens
75
+ * the band that reaches it.
76
+ * 2. SEAT: the two numbers do not govern the same machine. CC's keep-recent clear runs ONLY on the
77
+ * `context_hint` rejection leg — gated off by default — and behind a hard "saves ≥ 20000 tokens
78
+ * or don't bother" test, with request REFUSAL as the real backstop. Ours is on by default, is
79
+ * the only reduction between the frontier and the guard, and its backstop drops messages instead
80
+ * of refusing. Copying a constant across that difference is the "same name, different question"
81
+ * mistake, not parity.
82
+ *
83
+ * So the VALUE is an open adjudication (recorded with the probe above), while the two things CC
84
+ * unambiguously answers — the floor and the candidate-scoped window — are followed exactly.
85
+ */
86
+ export declare const DEFAULT_KEEP_RECENT_TOOL_RESULTS = 3;
87
+ /** Minimum kept results — CC `EUs` :397710 `Math.max(1, keepRecent)`. See
88
+ * {@link ContextEditOptions.keepRecentToolResults} for why both degenerate ends are unusable. */
89
+ export declare const MIN_KEEP_RECENT_TOOL_RESULTS = 1;
54
90
  export declare const COMPACTABLE_TOOLS: ReadonlySet<string>;
55
91
  export interface ContextEditOptions {
56
92
  /** Start clearing once estimated context tokens exceed this. */
57
93
  budgetTokens: number;
58
- /** Always keep the content of this many most-recent tool results. Default 3. */
94
+ /**
95
+ * Always keep the content of this many most-recent CANDIDATE tool results (candidates = results
96
+ * from {@link compactableTools}). Default {@link DEFAULT_KEEP_RECENT_TOOL_RESULTS} = 3 — see that
97
+ * constant for why CC's 5 is not adopted here.
98
+ *
99
+ * Floored at 1, which IS CC's rule (`EUs` `Math.max(1, keepRecent)`), whose own source note gives
100
+ * the reason: 0 leaves the model with zero working tool context, and in CC's `slice(-0)` spelling
101
+ * it degenerates the other way into keeping everything — two unusable extremes for one value.
102
+ * Ours degenerated identically (a 0 or negative `keep` cleared EVERY candidate). Values that are
103
+ * not a non-negative safe integer are REFUSED rather than floored: a NaN used to make this whole
104
+ * defense silently inert (`slice(0, NaN)` = clear nothing), which is the one outcome a context
105
+ * defense must never reach quietly, and there is no config door upstream to announce at — this
106
+ * option only ever arrives as a direct argument from an embedder.
107
+ */
59
108
  keepRecentToolResults?: number;
60
109
  /**
61
110
  * roadmap #6② (CC contentReplacementState parity): when set, a result's FULL TEXT is offloaded to
@@ -27,6 +27,17 @@ export const COMPACTION_TRIGGER_BUFFER_TOKENS = 13000;
27
27
  export function contextEditFrontier(window) {
28
28
  return Math.max(window - (CONTEXT_OUTPUT_RESERVE_TOKENS + COMPACTION_TRIGGER_BUFFER_TOKENS), Math.floor(window * EDIT_FRACTION));
29
29
  }
30
+ export const DEFAULT_KEEP_RECENT_TOOL_RESULTS = 3;
31
+ export const MIN_KEEP_RECENT_TOOL_RESULTS = 1;
32
+ function resolveKeepRecentToolResults(value) {
33
+ if (value === undefined)
34
+ return DEFAULT_KEEP_RECENT_TOOL_RESULTS;
35
+ if (!Number.isSafeInteger(value) || value < 0) {
36
+ throw new TypeError(`keepRecentToolResults must be a non-negative safe integer (got ${String(value)}); ` +
37
+ `omit it for the default ${DEFAULT_KEEP_RECENT_TOOL_RESULTS}`);
38
+ }
39
+ return Math.max(MIN_KEEP_RECENT_TOOL_RESULTS, value);
40
+ }
30
41
  export const COMPACTABLE_TOOLS = new Set([
31
42
  "Read",
32
43
  "Bash",
@@ -74,7 +85,7 @@ export function clearStaleToolResults(messages, opts) {
74
85
  if (total <= opts.budgetTokens) {
75
86
  return messages;
76
87
  }
77
- const keep = opts.keepRecentToolResults ?? 3;
88
+ const keep = resolveKeepRecentToolResults(opts.keepRecentToolResults);
78
89
  const compactable = opts.compactableTools ?? COMPACTABLE_TOOLS;
79
90
  const toolResultCandidates = messages.flatMap((m, i) => isToolResult(m) && !isCleared(m) && compactable.has(m.toolName) ? [{ idx: i, target: m }] : []);
80
91
  const clearable = toolResultCandidates.slice(0, Math.max(0, toolResultCandidates.length - keep));