@yeaft/webchat-agent 1.0.504 → 1.0.506

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 (100) hide show
  1. package/conversation.js +4 -2
  2. package/local-runtime/server/handlers/agent-conversation.js +11 -9
  3. package/local-runtime/server/handlers/client-conversation.js +28 -2
  4. package/local-runtime/version.json +1 -1
  5. package/local-runtime/web/app.bundle.js +99 -94
  6. package/local-runtime/web/app.bundle.js.gz +0 -0
  7. package/local-runtime/web/index.html +4 -3
  8. package/local-runtime/web/style.bundle.css +1 -1
  9. package/local-runtime/web/style.bundle.css.gz +0 -0
  10. package/local-runtime/web/vendor/katex/LICENSE +21 -0
  11. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  12. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  13. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  15. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  16. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  17. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  18. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  19. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  20. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  21. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  22. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  23. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  24. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  25. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  26. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  27. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  28. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  29. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  30. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  31. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  32. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  33. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  34. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  35. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  36. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  37. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  38. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  39. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  40. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  41. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  42. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  43. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  44. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  45. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  46. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  47. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  48. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  49. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  50. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  51. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  52. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  53. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  54. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  55. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  57. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  58. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  59. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  60. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  61. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  62. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  63. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  64. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  65. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  66. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  67. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  68. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  69. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  70. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  71. package/local-runtime/web/vendor/katex/katex.min.css +1 -0
  72. package/local-runtime/web/vendor.bundle.js +1 -0
  73. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  74. package/package.json +1 -1
  75. package/yeaft/cli.js +3 -0
  76. package/yeaft/config-api.js +16 -5
  77. package/yeaft/config.js +11 -2
  78. package/yeaft/conversation/history-index-worker.js +151 -0
  79. package/yeaft/conversation/history-index.js +96 -1
  80. package/yeaft/conversation/persist.js +21 -10
  81. package/yeaft/conversation/recall-relevance.js +103 -0
  82. package/yeaft/conversation/search.js +2 -1
  83. package/yeaft/debug-trace.js +15 -4
  84. package/yeaft/effort.js +159 -6
  85. package/yeaft/engine.js +180 -41
  86. package/yeaft/history-window.js +430 -5
  87. package/yeaft/llm/adapter.js +3 -2
  88. package/yeaft/llm/anthropic.js +97 -25
  89. package/yeaft/llm/openai-responses.js +71 -7
  90. package/yeaft/llm/provider-state.js +242 -0
  91. package/yeaft/llm/router.js +14 -2
  92. package/yeaft/llm/usage-accounting.js +3 -0
  93. package/yeaft/models.js +4 -1
  94. package/yeaft/snapshot-filter.js +1 -0
  95. package/yeaft/sub-agent/output-log.js +4 -0
  96. package/yeaft/sub-agent/prompt-queue.js +3 -0
  97. package/yeaft/sub-agent/runner.js +13 -2
  98. package/yeaft/tools/agent.js +3 -0
  99. package/yeaft/tools/send-message.js +2 -0
  100. package/yeaft/web-bridge.js +24 -17
@@ -0,0 +1,103 @@
1
+ // Deterministic, deliberately conservative relevance shared by indexed and
2
+ // in-memory turn recall. No provider calls and no transcript mutation.
3
+ const MAX_PROMPT_CHARS = 4096;
4
+ const MAX_TERMS = 8;
5
+ const STOP_WORDS = new Set(`a an and are as at be been but by can could did do does for from had has have how i if in is it its me my of on or our please should so that the their them there these they this to was we were what when where which who why will with would you your about again before earlier previous remember recall history message messages conversation turn turns tell show find help need want use using make get know explain answer question code file project problem fix work task test tests implementation implement change thanks continue revisit discuss discussed discussion decide decided follow-up
6
+ 的 了 是 在 和 与 或 我 你 他 她 它 我们 你们 他们 这个 那个 什么 怎么 如何 为什么 请 请问 帮 帮我 帮忙 可以 能 不能 是否 需要 想 要 再 还 又 也 就 都 把 将 给 对 从 到 上 下 中 里 有 没有 一下 一些 一个 这些 那些 之前 以前 上次 刚才 历史 记得 回忆 召回 消息 对话 问题 回答 内容 事情 继续 现在 今天 昨天 后来 然后 相关 具体 代码 文件 项目 实现 修改 功能 测试 方案 方法 工作 任务 谢谢 好的 好 看看 查找 搜索 查询 处理 解决 进行 使用 讨论 提到 记忆 总结 回顾 提醒`.split(/\s+/u));
7
+ const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
8
+
9
+ function isIdentifier(term) {
10
+ return /[\p{L}\d][_.:/@-][\p{L}\d]/u.test(term)
11
+ || /\p{L}.*\d|\d.*\p{L}/u.test(term)
12
+ || /[a-z][A-Z]/u.test(term);
13
+ }
14
+
15
+ /** Extract at most eight meaningful lexical terms; generic prompts yield []. */
16
+ export function extractRecallTerms(prompt) {
17
+ // VP routing is not subject matter: "@vp-omni 继续" must not recall every
18
+ // earlier message addressed to that VP just because it looks like an ID.
19
+ const text = typeof prompt === 'string'
20
+ ? prompt.slice(0, MAX_PROMPT_CHARS).replace(/@vp-[A-Za-z0-9_-]+\b/gu, ' ') : '';
21
+ const tokens = [];
22
+ // Keep paths, issue IDs, snake_case and camelCase intact before segmentation.
23
+ const rest = text.replace(/[\p{L}\p{N}]+(?:[_.:/@-][\p{L}\p{N}]+)+|[A-Za-z][A-Za-z0-9]*/gu, token => {
24
+ tokens.push(token);
25
+ return ' ';
26
+ });
27
+ let singleHan = '';
28
+ const flushHan = () => {
29
+ if (singleHan.length >= 2) tokens.push(singleHan);
30
+ singleHan = '';
31
+ };
32
+ for (const part of segmenter.segment(rest)) {
33
+ // ICU dictionaries sometimes split technical words such as 缓存 into
34
+ // individual Han characters. Preserve short adjacent runs, not stop words.
35
+ if (part.isWordLike && /^\p{Script=Han}$/u.test(part.segment) && !STOP_WORDS.has(part.segment)) {
36
+ singleHan += part.segment;
37
+ if (singleHan.length === 4) flushHan();
38
+ } else {
39
+ flushHan();
40
+ if (part.isWordLike) tokens.push(part.segment);
41
+ }
42
+ }
43
+ flushHan();
44
+ const seen = new Set();
45
+ const terms = tokens.filter(term => {
46
+ const key = term.toLocaleLowerCase();
47
+ if (seen.has(key) || STOP_WORDS.has(key) || /^\d+$/u.test(key)
48
+ || Array.from(key).length < 2 || key.length > 96) return false;
49
+ seen.add(key);
50
+ return true;
51
+ });
52
+ // Identifiers are more useful than prose if a long prompt exhausts the cap.
53
+ return terms.sort((a, b) => Number(isIdentifier(b)) - Number(isIdentifier(a))).slice(0, MAX_TERMS);
54
+ }
55
+
56
+ /**
57
+ * Score full visible turn text. stats.termDocumentFrequency may be a bounded
58
+ * candidate sample, not corpus-wide IDF; its sample size must be explicit.
59
+ * Returns explainable rejection reasons rather than weak positive matches.
60
+ */
61
+ export function scoreRecallTurn(promptOrTerms, text, stats = {}) {
62
+ const terms = (Array.isArray(promptOrTerms) ? promptOrTerms : extractRecallTerms(promptOrTerms)).slice(0, MAX_TERMS);
63
+ const body = String(text || '').toLocaleLowerCase();
64
+ const matchedTerms = terms.filter(term => {
65
+ const key = term.toLocaleLowerCase();
66
+ if (/^[a-z0-9_]+$/u.test(key)) {
67
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68
+ return new RegExp(`(?<![a-z0-9_])${escaped}(?![a-z0-9_])`, 'u').test(body);
69
+ }
70
+ return body.includes(key);
71
+ });
72
+ const identifierMatches = matchedTerms.filter(isIdentifier);
73
+ const independentTerms = matchedTerms.filter(term => !matchedTerms.some(other => (
74
+ other !== term && other.toLocaleLowerCase().includes(term.toLocaleLowerCase())
75
+ )));
76
+ const coverage = terms.length ? matchedTerms.length / terms.length : 0;
77
+ const sampleSize = Math.max(0, Number(stats.sampleSize) || 0);
78
+ const distinctiveness = matchedTerms.length ? Math.max(...matchedTerms.map(term => {
79
+ const frequency = stats.termDocumentFrequency?.[term.toLocaleLowerCase()];
80
+ return sampleSize >= 8 && Number.isFinite(frequency) ? 1 - frequency / sampleSize : 1;
81
+ })) : 0;
82
+ let reason = 'relevant';
83
+ if (!terms.length) reason = 'generic_prompt';
84
+ else if (!matchedTerms.length) reason = 'no_match';
85
+ else if (!identifierMatches.length && independentTerms.length < 2) reason = 'insufficient_keywords';
86
+ else if (!identifierMatches.length && coverage < 0.5) reason = 'low_coverage';
87
+ else if (!identifierMatches.length && distinctiveness < 0.15) reason = 'low_distinctiveness';
88
+ const score = reason === 'relevant'
89
+ ? Math.round((independentTerms.length * 2 + identifierMatches.length * 4 + coverage * 2 + distinctiveness) * 100) / 100
90
+ : 0;
91
+ return { score, matchedTerms, reason, coverage, distinctiveness, identifierMatches, sampleSize };
92
+ }
93
+
94
+ export const RECALL_LIMITS = Object.freeze({
95
+ maxTerms: MAX_TERMS,
96
+ maxCandidates: 128,
97
+ candidatesPerTerm: 32,
98
+ shortTermRows: 512,
99
+ maxBoundaryRows: 4096,
100
+ maxTurnRows: 64,
101
+ maxTurnBytes: 64 * 1024,
102
+ maxReadBytes: 256 * 1024,
103
+ });
@@ -54,8 +54,9 @@ function recordMessageScan(telemetry) {
54
54
  }
55
55
 
56
56
  function withSource(msg, source) {
57
+ const { providerState, thinkingBlocks, ...publicMessage } = msg;
57
58
  return {
58
- ...msg,
59
+ ...publicMessage,
59
60
  content: searchableContent(msg),
60
61
  sessionId: msg.sessionId || source.sessionId || null,
61
62
  historySource: source.kind,
@@ -621,6 +621,8 @@ function expandTrace(trace) {
621
621
  const turnsById = new Map([[trace.requestId || trace.traceId, summarizeTrace(trace, true)]]);
622
622
  let snapshot = null;
623
623
  let rawRequest = trace?.baseRequest?.rawRequest ?? null;
624
+ let latestRawRequestLoop = null;
625
+ let latestSystemPromptLoop = null;
624
626
  const loops = [];
625
627
  for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
626
628
  snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
@@ -633,8 +635,8 @@ function expandTrace(trace) {
633
635
  loopInstanceId: loop.loopInstanceId || loop.turnRowId || null,
634
636
  loopNumber: loop.loopNumber || 0,
635
637
  model: loop.model || null,
636
- // Request snapshots are attached only to the latest loop below. Earlier
637
- // loops retain their responses, calls and timing, not repeated history.
638
+ // Keep only the latest available raw request and system prompt, each on
639
+ // its actual source loop. Messages remain limited to the final loop.
638
640
  systemPrompt: '',
639
641
  messages: [],
640
642
  response: loop.response || '',
@@ -650,12 +652,21 @@ function expandTrace(trace) {
650
652
  vpId: trace.vpId || null,
651
653
  threadId: trace.threadId || null,
652
654
  });
655
+ const current = loops.at(-1);
656
+ if (rawRequest != null) {
657
+ if (latestRawRequestLoop) latestRawRequestLoop.rawRequest = null;
658
+ current.rawRequest = rawRequest;
659
+ latestRawRequestLoop = current;
660
+ }
661
+ if (snapshot.systemPrompt) {
662
+ if (latestSystemPromptLoop) latestSystemPromptLoop.systemPrompt = '';
663
+ current.systemPrompt = snapshot.systemPrompt;
664
+ latestSystemPromptLoop = current;
665
+ }
653
666
  }
654
667
  const latest = loops.at(-1);
655
668
  if (latest && snapshot) {
656
- latest.systemPrompt = snapshot.systemPrompt || '';
657
669
  latest.messages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
658
- latest.rawRequest = rawRequest;
659
670
  }
660
671
  return { loops, turns: Array.from(turnsById.values()) };
661
672
  }
package/yeaft/effort.js CHANGED
@@ -14,14 +14,15 @@
14
14
  *
15
15
  * Red lines:
16
16
  * • Never error on unknown scenario — default to 'max'.
17
- * • Feature flag YEAFT_THINKING_V1 is enforced at the adapter/router
18
- * layer; this module just computes the intended value. If the flag
19
- * is off, adapters drop it anyway.
20
- * • Unsupported models silently drop effort at the router — this
21
- * module does NOT consult the capability matrix.
17
+ * • The ordinary picker preserves existing scenario defaults. Child effort
18
+ * is separately constrained by the capability-aware final payload helpers.
19
+ * • Child ceilings cannot be disabled by YEAFT_THINKING_V1, user overrides,
20
+ * routing, nesting, or extraBody. Unsupported models omit effort fields.
22
21
  */
23
22
 
24
- import { normalizeEffort } from './models.js';
23
+ import {
24
+ normalizeEffort, getThinkingCapability, getModelEffortOptions, thinkingBudgetForEffort,
25
+ } from './models.js';
25
26
 
26
27
  /**
27
28
  * Number of tool-loop turns past which a query is considered "complex"
@@ -116,3 +117,155 @@ export function parseEffortPrefix(prompt) {
116
117
  const cleanedPrompt = prompt.slice(m[0].length);
117
118
  return { effort, cleanedPrompt };
118
119
  }
120
+
121
+ // Ordinal levels, not lexical sorting or cross-model token-budget equivalence.
122
+ export const EFFORT_LEVELS = Object.freeze(['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
123
+ const effortRank = value => EFFORT_LEVELS.indexOf(normalizeEffort(value));
124
+ const atMost = (value, ceiling) => effortRank(value) >= 0 && effortRank(value) <= effortRank(ceiling);
125
+
126
+ /** Copy only durable decision fields; never retain a mutable request/config object. */
127
+ export function snapshotEffortDecision(decision = null) {
128
+ return Object.freeze({
129
+ requested: normalizeEffort(decision?.requested),
130
+ effective: normalizeEffort(decision?.effective),
131
+ source: typeof decision?.source === 'string' ? decision.source : 'unknown',
132
+ model: typeof decision?.model === 'string' ? decision.model : null,
133
+ wireMode: typeof decision?.wireMode === 'string' ? decision.wireMode : 'omitted',
134
+ thinkingEnabled: decision?.thinkingEnabled === true,
135
+ cap: normalizeEffort(decision?.cap),
136
+ ...(Number.isFinite(decision?.budgetTokens) ? { budgetTokens: decision.budgetTokens } : {}),
137
+ });
138
+ }
139
+
140
+ /** Capture the decision belonging to the provider response that generated a tool call. */
141
+ export function captureParentEffortDecision(ctx = {}) {
142
+ return snapshotEffortDecision(ctx.effortDecision ?? ctx.parentEngineDeps?.effortDecision);
143
+ }
144
+
145
+ function effortError(model, target, detail = '') {
146
+ const error = new Error(`Sub-agent model "${model}" cannot express effort <= ${target}${detail ? ` (${detail})` : ''}. Select a compatible child model.`);
147
+ error.code = 'SUB_AGENT_EFFORT_UNREPRESENTABLE';
148
+ return error;
149
+ }
150
+
151
+ function capabilityContext(protocol, effortContext) {
152
+ return { ...effortContext, protocol: protocol || effortContext?.protocol };
153
+ }
154
+
155
+ /**
156
+ * Resolve the non-disableable child ceiling using the actual model's capabilities.
157
+ * Unknown/omitted parent wire defaults are conservative medium, never chat/max.
158
+ */
159
+ export function resolveSubAgentEffort({ parentDecision = null, model, effortContext = {}, protocol } = {}) {
160
+ const parent = snapshotEffortDecision(parentDecision);
161
+ // An unsupported intermediate model has no wire effort, but must not erase
162
+ // an inherited lower ceiling when it delegates again.
163
+ let parentTarget = parent.effective || 'medium';
164
+ if (parent.cap && atMost(parent.cap, parentTarget)) parentTarget = parent.cap;
165
+ const target = atMost(parentTarget, 'high') ? parentTarget : 'high';
166
+ const context = capabilityContext(protocol, effortContext);
167
+ const capability = getThinkingCapability(model, context);
168
+ const base = {
169
+ requested: parentTarget, source: parent.effective ? 'inherited' : 'fallback',
170
+ model, cap: target, thinkingEnabled: false,
171
+ };
172
+ if (!capability.supportsThinking || capability.thinkingProtocol === 'none') {
173
+ return snapshotEffortDecision({ ...base, effective: null, wireMode: 'unsupported' });
174
+ }
175
+ const supported = getModelEffortOptions(model, context).filter(value => atMost(value, target));
176
+ const effective = EFFORT_LEVELS.filter(value => supported.includes(value)).at(-1);
177
+ if (!effective) throw effortError(model, target);
178
+ const wireMode = protocol === 'openai-responses' || capability.thinkingProtocol === 'openai-reasoning'
179
+ ? 'reasoning-effort'
180
+ : capability.thinkingProtocol === 'anthropic-adaptive' ? 'adaptive' : 'manual';
181
+ return snapshotEffortDecision({ ...base, effective, wireMode, thinkingEnabled: true });
182
+ }
183
+
184
+ function manualEffortForBudget(model, budget) {
185
+ if (!Number.isFinite(budget) || budget <= 0) return null;
186
+ // Round upward: a nonstandard budget must never masquerade as a lower tier.
187
+ return ['low', 'medium', 'high', 'max'].find(level => budget <= thinkingBudgetForEffort(model, level)) || 'ultra';
188
+ }
189
+
190
+ /**
191
+ * Read the FINAL wire payload. requested is observability only, not effective.
192
+ * Call after all extraBody/feature-flag/mapping changes, before serialization.
193
+ */
194
+ export function captureEffortDecision({ body = {}, model, protocol, effortContext = {}, requested = null, source = 'scenario' } = {}) {
195
+ const context = capabilityContext(protocol, effortContext);
196
+ const capability = getThinkingCapability(model, context);
197
+ const base = { requested, source, model, cap: null, thinkingEnabled: false };
198
+ if (!capability.supportsThinking || capability.thinkingProtocol === 'none') {
199
+ return snapshotEffortDecision({ ...base, effective: null, wireMode: 'unsupported' });
200
+ }
201
+ if (protocol === 'openai-responses') {
202
+ const effective = normalizeEffort(body.reasoning?.effort);
203
+ if (effective) return snapshotEffortDecision({ ...base, effective, wireMode: 'reasoning-effort', thinkingEnabled: true });
204
+ } else if (body.thinking?.type === 'enabled') {
205
+ const budgetTokens = body.thinking.budget_tokens;
206
+ return snapshotEffortDecision({ ...base, effective: manualEffortForBudget(model, budgetTokens), wireMode: 'manual', thinkingEnabled: true, budgetTokens });
207
+ } else if (body.thinking?.type === 'adaptive') {
208
+ const effective = normalizeEffort(body.output_config?.effort);
209
+ if (effective) return snapshotEffortDecision({ ...base, effective, wireMode: 'adaptive', thinkingEnabled: true });
210
+ }
211
+ const modelDefault = body.thinking?.type === 'disabled' ? null : normalizeEffort(capability.defaultEffort);
212
+ return snapshotEffortDecision({ ...base, effective: modelDefault, source: modelDefault ? 'model-default' : source, wireMode: 'omitted' });
213
+ }
214
+
215
+ function removeEffortField(body, key) {
216
+ if (!body[key] || typeof body[key] !== 'object' || Array.isArray(body[key])) {
217
+ delete body[key];
218
+ return;
219
+ }
220
+ const { effort: _effort, ...rest } = body[key];
221
+ if (Object.keys(rest).length) body[key] = rest;
222
+ else delete body[key];
223
+ }
224
+
225
+ /**
226
+ * Mutate the FINAL provider body in place and return its immutable child decision.
227
+ * The router must preserve effortConstraint even when YEAFT_THINKING_V1 is off.
228
+ * All adapter stream/call paths invoke this AFTER extraBody, BEFORE fetch, and
229
+ * must not subsequently rewrite reasoning/thinking/output_config/max_tokens.
230
+ * @param {object} body Final body owned by the adapter (never caller config).
231
+ * @param {{ model: string, protocol: string, effortContext?: object,
232
+ * effortConstraint: { parentDecision: object|null } }} options
233
+ */
234
+ export function enforceSubAgentEffortPayload(body, { model, protocol, effortContext = {}, effortConstraint } = {}) {
235
+ if (!effortConstraint) return captureEffortDecision({ body, model, protocol, effortContext });
236
+ const decision = resolveSubAgentEffort({ parentDecision: effortConstraint.parentDecision, model, protocol, effortContext });
237
+ if (decision.wireMode === 'unsupported') {
238
+ removeEffortField(body, 'reasoning');
239
+ removeEffortField(body, 'output_config');
240
+ delete body.thinking;
241
+ return decision;
242
+ }
243
+ const context = capabilityContext(protocol, effortContext);
244
+ const options = getModelEffortOptions(model, context);
245
+ const wireEffort = protocol === 'openai-responses' ? body.reasoning?.effort : body.output_config?.effort;
246
+ const effective = options.includes(wireEffort) && atMost(wireEffort, decision.effective)
247
+ ? wireEffort : decision.effective;
248
+ if (protocol === 'openai-responses') {
249
+ body.reasoning = { ...(body.reasoning && typeof body.reasoning === 'object' && !Array.isArray(body.reasoning) ? body.reasoning : {}), effort: effective };
250
+ delete body.thinking;
251
+ removeEffortField(body, 'output_config');
252
+ } else if (decision.wireMode === 'adaptive') {
253
+ body.thinking = { type: 'adaptive' };
254
+ body.output_config = { ...(body.output_config && typeof body.output_config === 'object' && !Array.isArray(body.output_config) ? body.output_config : {}), effort: effective };
255
+ removeEffortField(body, 'reasoning');
256
+ } else {
257
+ const capability = getThinkingCapability(model, context);
258
+ let budget = thinkingBudgetForEffort(model, effective);
259
+ if (!budget) throw effortError(model, decision.cap, 'no manual thinking budget');
260
+ if (Number.isFinite(capability.maxBudgetTokens)) budget = Math.min(budget, capability.maxBudgetTokens);
261
+ const supplied = body.thinking?.type === 'enabled' ? body.thinking.budget_tokens : null;
262
+ if (Number.isInteger(supplied) && supplied >= 1024) budget = Math.min(budget, supplied);
263
+ if (Number.isFinite(body.max_tokens)) budget = Math.min(budget, Math.floor(body.max_tokens) - 1);
264
+ if (budget < 1024) throw effortError(model, decision.cap, 'max_tokens must allow at least 1024 thinking tokens');
265
+ body.thinking = { type: 'enabled', budget_tokens: budget };
266
+ removeEffortField(body, 'output_config');
267
+ removeEffortField(body, 'reasoning');
268
+ return snapshotEffortDecision({ ...decision, effective: manualEffortForBudget(model, budget), budgetTokens: budget });
269
+ }
270
+ return snapshotEffortDecision({ ...decision, effective });
271
+ }