@ixo/oracle-runtime-workers 0.10.0 → 0.11.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 (48) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +29 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +227 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +875 -146
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/plugin-api/tool-helper.ts +9 -1
  39. package/src/plugin-api/types.ts +86 -0
  40. package/src/plugins/index.ts +3 -0
  41. package/src/plugins/matrix-group-chats/README.md +70 -0
  42. package/src/plugins/matrix-group-chats/index.ts +132 -0
  43. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  44. package/src/plugins/tasks/tasks-tools.ts +4 -0
  45. package/src/shell/app.ts +66 -0
  46. package/src/sqlite/sqlite-saver.ts +81 -0
  47. package/src/tasks/scheduler.ts +134 -3
  48. package/src/tasks/store.ts +22 -0
@@ -14,6 +14,9 @@ import type {
14
14
  import type { MainAgentArgs, MainAgentBuildResult } from './main-agent-types';
15
15
  import { renderTier1, type Tier1Entry } from './manifest';
16
16
  import { buildMetaTools } from './meta-tools';
17
+ import { buildReadResultTool } from './read-result-tool';
18
+ import { describeBudget } from './context-budget';
19
+ import { createContextGuardMiddleware } from './middlewares/context-guard';
17
20
  import {
18
21
  createByoHistorySanitizerMiddleware,
19
22
  createCapabilityGateMiddleware,
@@ -52,6 +55,7 @@ import {
52
55
  type RuntimeStateInput,
53
56
  } from './runtime-context';
54
57
  import { MainAgentGraphState } from './state';
58
+ import { toolEffectOf } from './middlewares/tool-marks';
55
59
  import { collectSubAgentsWithFallback } from './sub-agent-fallback';
56
60
  import { computeSubAgentToolName } from './subagent-as-tool';
57
61
  import { wrapPluginTool } from './wrap-plugin-tool';
@@ -119,6 +123,7 @@ export async function createMainAgent(
119
123
  checkpointer,
120
124
  abortSignal,
121
125
  byoProvider,
126
+ contextBudget,
122
127
  hooks,
123
128
  } = args;
124
129
 
@@ -227,12 +232,31 @@ export async function createMainAgent(
227
232
  const silentTools = selectByVisibility(allTools, manifestViz, 'silent');
228
233
 
229
234
  // ── 4. Wrap tools (meta + plugin) so handlers receive a RuntimeContext ──
230
- const metaTools = buildMetaTools({
231
- manifestRegistry: registries.manifests,
232
- toolRegistry: registries.tools,
233
- });
235
+ const metaTools = [
236
+ ...buildMetaTools({
237
+ manifestRegistry: registries.manifests,
238
+ toolRegistry: registries.tools,
239
+ }),
240
+ // Pages through tool results the result cap saved whole (result-cap.ts);
241
+ // its chunks stay well under the cap so a page is never capped itself.
242
+ ...(hooks?.readResult
243
+ ? [
244
+ buildReadResultTool({
245
+ read: hooks.readResult,
246
+ maxChars: Math.min(
247
+ 16_000,
248
+ Math.max(
249
+ 512,
250
+ Math.floor((contextBudget?.resultCapChars ?? 32_000) / 2),
251
+ ),
252
+ ),
253
+ }),
254
+ ]
255
+ : []),
256
+ ];
234
257
 
235
258
  const fallbackContext = runConfig.context;
259
+ const resultCap = hooks?.resultCap;
236
260
  const wrap = (entry: CollectedTool) =>
237
261
  wrapPluginTool(entry.tool, {
238
262
  ambient,
@@ -240,6 +264,7 @@ export async function createMainAgent(
240
264
  pluginTitle: titleByPlugin.get(entry.pluginName),
241
265
  sharedFactory,
242
266
  fallbackContext,
267
+ ...(resultCap ? { resultCap } : {}),
243
268
  });
244
269
 
245
270
  // ── 5. Sub-agents — bind all at compile time; gating happens at runtime ─
@@ -256,8 +281,21 @@ export async function createMainAgent(
256
281
  sharedFactory,
257
282
  fallbackContext,
258
283
  subAgents: subAgentEntries,
284
+ ...(hooks?.toolMiddlewares
285
+ ? { extraMiddleware: hooks.toolMiddlewares }
286
+ : {}),
287
+ ...(resultCap ? { resultCap } : {}),
259
288
  });
260
289
 
290
+ // Effect of every tool the model can call (durable runs: what may run
291
+ // again after a reset). Meta-tools only touch graph state; a sub-agent
292
+ // is opaque, hence a write.
293
+ const toolEffects = new Map<string, 'read' | 'write'>();
294
+ for (const t of metaTools) toolEffects.set(t.name, 'read');
295
+ for (const { tool } of allTools)
296
+ toolEffects.set(tool.name, toolEffectOf(tool));
297
+ for (const t of subAgentTools) toolEffects.set(t.name, 'write');
298
+
261
299
  ambient.logger.debug?.(
262
300
  `[main-agent] binding summary (all bound; gated at runtime): ` +
263
301
  `eager=${eagerTools.length} onDemand=${onDemandTools.length} silent=${silentTools.length} ` +
@@ -271,6 +309,7 @@ export async function createMainAgent(
271
309
  state: wrapState,
272
310
  sharedFactory,
273
311
  fallbackContext,
312
+ ...(resultCap ? { resultCap } : {}),
274
313
  }),
275
314
  ),
276
315
  ...eagerTools.map(wrap),
@@ -312,8 +351,12 @@ export async function createMainAgent(
312
351
  const resolveModel = hooks?.resolveModel ?? ambient.llm.get.bind(ambient.llm);
313
352
 
314
353
  const middleware = [
315
- // Outermost: a turn that died between a tool call and its result (abort,
316
- // object reset) must not poison every later model request on the thread.
354
+ // Outermost of all: the durable-run tool marks (write-ahead record per
355
+ // tool call, resume policy, continuation note) must see every call
356
+ // before validation and retries do.
357
+ ...(hooks?.toolMiddlewares ?? []),
358
+ // A turn that died between a tool call and its result (abort, object
359
+ // reset) must not poison every later model request on the thread.
317
360
  createDanglingToolCallRepairMiddleware({ logger: ambient.logger }),
318
361
  // Then, as on Node: rewrite cross-provider reasoning residue before the
319
362
  // summarizer condenses (a well-formed) history.
@@ -322,10 +365,26 @@ export async function createMainAgent(
322
365
  // Without this, thread state — reloaded, re-serialized, and re-uploaded to
323
366
  // the owner store on every turn — grows without bound.
324
367
  // The summarizer's own model call is internal: it must never stream its
325
- // tokens into the user's reply (belt: no streaming here; braces: the SSE
326
- // stream drops `lc_source: 'summarization'` events).
368
+ // tokens into the user's reply. That is guaranteed by the SSE producer,
369
+ // which drops every model event tagged `lc_source: 'summarization'`
370
+ // (the middleware tags its call). The model itself keeps whatever
371
+ // transport its provider needs — forcing it non-streaming broke the
372
+ // ChatGPT backend, which accepts streamed requests only.
327
373
  createSummarizationMiddleware({
328
- model: resolveModel('routing', { disableStreaming: true }),
374
+ model: resolveModel('routing'),
375
+ logger: ambient.logger,
376
+ // Window-derived thresholds when the host resolved a budget: summarize
377
+ // at a fraction of the model's window (tokens only, unless the
378
+ // operator asked for a message trigger), the summarizer reading at
379
+ // most what fits the window.
380
+ ...(contextBudget
381
+ ? {
382
+ triggerTokens: contextBudget.summarizeAtTokens,
383
+ triggerMessages: contextBudget.summarizeTriggerMessages ?? null,
384
+ keepMessages: contextBudget.keepMessages,
385
+ summaryInputTokens: contextBudget.summaryInputTokens,
386
+ }
387
+ : {}),
329
388
  }),
330
389
  createCapabilityGateMiddleware({
331
390
  pluginByToolName,
@@ -385,7 +444,24 @@ export async function createMainAgent(
385
444
  : []),
386
445
  ...pluginMiddlewares,
387
446
  ...(hooks?.middlewares ?? []),
447
+ // Innermost: sees the request exactly as it goes to the provider. Prunes
448
+ // old tool results under pressure, refuses what cannot fit, and learns
449
+ // the window from a provider overflow (context-guard.ts).
450
+ ...(contextBudget
451
+ ? [
452
+ createContextGuardMiddleware({
453
+ budget: contextBudget,
454
+ ...(hooks?.onContextOverflow
455
+ ? { onOverflow: hooks.onContextOverflow }
456
+ : {}),
457
+ ...(hooks?.onContextEvent ? { onEvent: hooks.onContextEvent } : {}),
458
+ logger: ambient.logger,
459
+ }),
460
+ ]
461
+ : []),
388
462
  ];
463
+ if (contextBudget)
464
+ ambient.logger.log(`[context] ${describeBudget(contextBudget)}`);
389
465
 
390
466
  // ── 7. Prompt composition ───────────────────────────────────────────────
391
467
  const eagerEntries: Tier1Entry[] = manifestEntries.filter(
@@ -435,6 +511,7 @@ export async function createMainAgent(
435
511
  agent,
436
512
  systemPrompt,
437
513
  boundToolNames: tools.map((t) => t.name),
514
+ toolEffects,
438
515
  context: runConfig.context,
439
516
  };
440
517
  }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Keeps every model request inside the model's window.
3
+ *
4
+ * Three layers, all on the request only (graph state and the transcript are
5
+ * never rewritten here):
6
+ *
7
+ * 1. **Pressure pruning.** Above `pruneAtTokens` (35% of the window), tool
8
+ * results outside the kept tail are demoted to one-line placeholders
9
+ * (a capped result keeps its `read_result` handle) and results whose
10
+ * text is identical to a later one are replaced by a back-reference.
11
+ * No model call, no storage — Hermes' deterministic passes.
12
+ * 2. **The hard cap.** A request still above `requestCapTokens` after a
13
+ * harder prune is refused with a clear error instead of being sent to
14
+ * fail at the provider.
15
+ * 3. **Overflow recovery.** When the provider rejects the request anyway,
16
+ * the limit it names is learned (the window is lowered for good), the
17
+ * request is pruned hard, and it is retried once.
18
+ *
19
+ * Token counts are chars/4 estimates; the thresholds leave the slack for it.
20
+ */
21
+ import {
22
+ AIMessage,
23
+ ToolMessage,
24
+ type BaseMessage,
25
+ } from '@langchain/core/messages';
26
+ import { convertToOpenAITool } from '@langchain/core/utils/function_calling';
27
+ import { type AgentMiddleware, createMiddleware } from 'langchain';
28
+ import type { Logger } from '../../plugin-api/types';
29
+ import { estimateContentTokens, type ContextBudget } from '../context-budget';
30
+ import { isContextOverflowError } from '../context-window';
31
+ import { NOOP_LOGGER } from '../utils';
32
+ import { cappedMetaOf } from './result-cap';
33
+
34
+ export class ContextOverflowError extends Error {
35
+ readonly retryable = false;
36
+
37
+ constructor(
38
+ message: string,
39
+ readonly cause?: unknown,
40
+ ) {
41
+ super(message);
42
+ this.name = 'ContextOverflowError';
43
+ }
44
+ }
45
+
46
+ /**
47
+ * What the guard did to a request, for the host's per-session counters
48
+ * (`GET /debug/context?session=`): the stage that pruned and how much it
49
+ * saved, a provider overflow that was retried, or a request it refused.
50
+ */
51
+ export type ContextGuardEvent =
52
+ | {
53
+ kind: 'prune';
54
+ stage: 'soft' | 'hard' | 'overflow';
55
+ pruned: number;
56
+ beforeTokens: number;
57
+ afterTokens: number;
58
+ }
59
+ | { kind: 'overflow-retry'; learnedWindow?: number }
60
+ | { kind: 'refused'; tokens: number };
61
+
62
+ export interface ContextGuardOptions {
63
+ budget: ContextBudget;
64
+ /**
65
+ * A provider rejected the request as too long: learn from it. Returns the
66
+ * new window (tokens) when the error named one.
67
+ */
68
+ onOverflow?: (error: unknown) => Promise<number | undefined>;
69
+ /** Observes every prune, overflow retry and refusal (never throws into the turn). */
70
+ onEvent?: (event: ContextGuardEvent) => void;
71
+ logger?: Logger;
72
+ }
73
+
74
+ export interface PruneOptions {
75
+ /** Messages at the end that are never touched. */
76
+ keepTail: number;
77
+ /** Tool results shorter than this are left alone. */
78
+ demoteMinChars: number;
79
+ }
80
+
81
+ export const PRUNE_SOFT: PruneOptions = { keepTail: 10, demoteMinChars: 600 };
82
+ export const PRUNE_HARD: PruneOptions = { keepTail: 4, demoteMinChars: 200 };
83
+
84
+ function textOf(content: unknown): string {
85
+ if (typeof content === 'string') return content;
86
+ try {
87
+ return JSON.stringify(content) ?? '';
88
+ } catch {
89
+ return '';
90
+ }
91
+ }
92
+
93
+ function placeholder(
94
+ message: ToolMessage,
95
+ text: string,
96
+ reason: 'pruned' | 'duplicate',
97
+ ): ToolMessage {
98
+ const name = message.name ?? 'tool';
99
+ const meta = cappedMetaOf(message);
100
+ const body =
101
+ reason === 'duplicate'
102
+ ? `[${name} result: identical to a later ${name} result, omitted]`
103
+ : `[${name} result (${text.length} characters) pruned from context${meta?.id ? `; the full result is saved as ${meta.id} — read_result({ id: "${meta.id}" }) reads it` : ''}]`;
104
+ return new ToolMessage({
105
+ ...(message.id ? { id: message.id } : {}),
106
+ tool_call_id: message.tool_call_id,
107
+ name,
108
+ status: message.status,
109
+ content: body,
110
+ additional_kwargs: { ...message.additional_kwargs, pruned: reason },
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Old tool results → one-liners; duplicates → back-references. The last
116
+ * `keepTail` messages are returned as they are. Pure: new message objects
117
+ * with the same ids, the input untouched.
118
+ */
119
+ export function pruneToolResults(
120
+ messages: BaseMessage[],
121
+ options: PruneOptions,
122
+ ): { messages: BaseMessage[]; pruned: number } {
123
+ const cutoff = Math.max(0, messages.length - options.keepTail);
124
+ const out = [...messages];
125
+ let pruned = 0;
126
+ // Duplicates: keep the LAST full copy anywhere; earlier identical results
127
+ // (outside the tail) point at it.
128
+ const seenLater = new Set<string>();
129
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
130
+ const m = messages[i]!;
131
+ if (!ToolMessage.isInstance(m)) continue;
132
+ const text = textOf(m.content);
133
+ if (i >= cutoff) {
134
+ if (text.length >= 40) seenLater.add(text);
135
+ continue;
136
+ }
137
+ if (text.length >= 40 && seenLater.has(text)) {
138
+ out[i] = placeholder(m, text, 'duplicate');
139
+ pruned += 1;
140
+ continue;
141
+ }
142
+ if (text.length >= 40) seenLater.add(text);
143
+ if (text.length > options.demoteMinChars) {
144
+ out[i] = placeholder(m, text, 'pruned');
145
+ pruned += 1;
146
+ }
147
+ }
148
+ return { messages: out, pruned };
149
+ }
150
+
151
+ /** chars/4 over the request: system prompt, tool schemas, messages. */
152
+ export function estimateRequestTokens(input: {
153
+ systemTokens: number;
154
+ schemaTokens: number;
155
+ messages: BaseMessage[];
156
+ }): number {
157
+ let tokens = input.systemTokens + input.schemaTokens;
158
+ for (const m of input.messages) {
159
+ tokens += estimateContentTokens(m.content) + 4;
160
+ if (AIMessage.isInstance(m) && m.tool_calls?.length)
161
+ tokens += estimateContentTokens(m.tool_calls);
162
+ }
163
+ return tokens;
164
+ }
165
+
166
+ export function createContextGuardMiddleware(
167
+ options: ContextGuardOptions,
168
+ ): AgentMiddleware {
169
+ const logger = options.logger ?? NOOP_LOGGER;
170
+ const { budget } = options;
171
+ const emit = (event: ContextGuardEvent): void => {
172
+ try {
173
+ options.onEvent?.(event);
174
+ } catch (err) {
175
+ logger.warn(
176
+ `[context] onEvent failed: ${err instanceof Error ? err.message : String(err)}`,
177
+ );
178
+ }
179
+ };
180
+ const schemaTokensCache = new WeakMap<object, number>();
181
+ const schemaTokensOf = (tools: unknown[]): number => {
182
+ let total = 0;
183
+ for (const entry of tools) {
184
+ if (!entry || typeof entry !== 'object') continue;
185
+ let n = schemaTokensCache.get(entry);
186
+ if (n === undefined) {
187
+ try {
188
+ n = estimateContentTokens(
189
+ 'schema' in entry ? convertToOpenAITool(entry as never) : entry,
190
+ );
191
+ } catch {
192
+ n = 0;
193
+ }
194
+ schemaTokensCache.set(entry, n);
195
+ }
196
+ total += n;
197
+ }
198
+ return total;
199
+ };
200
+ return createMiddleware({
201
+ name: 'ContextGuardMiddleware',
202
+ wrapModelCall: async (request, handler) => {
203
+ const systemTokens = estimateContentTokens(
204
+ request.systemMessage?.content ?? '',
205
+ );
206
+ const schemaTokens = schemaTokensOf(request.tools ?? []);
207
+ const size = (messages: BaseMessage[]) =>
208
+ estimateRequestTokens({ systemTokens, schemaTokens, messages });
209
+ let messages = request.messages;
210
+ let tokens = size(messages);
211
+ const shrink = (
212
+ opts: PruneOptions,
213
+ stage: 'soft' | 'hard' | 'overflow',
214
+ why: string,
215
+ ): void => {
216
+ const before = tokens;
217
+ const result = pruneToolResults(messages, opts);
218
+ messages = result.messages;
219
+ tokens = size(messages);
220
+ if (result.pruned > 0) {
221
+ logger.log(
222
+ `[context] ${why}: pruned ${result.pruned} tool result(s), ~${before} → ~${tokens} tokens (window ${budget.windowTokens})`,
223
+ );
224
+ emit({
225
+ kind: 'prune',
226
+ stage,
227
+ pruned: result.pruned,
228
+ beforeTokens: before,
229
+ afterTokens: tokens,
230
+ });
231
+ }
232
+ };
233
+ if (tokens > budget.pruneAtTokens)
234
+ shrink(PRUNE_SOFT, 'soft', 'over the prune threshold');
235
+ if (tokens > budget.requestCapTokens)
236
+ shrink(PRUNE_HARD, 'hard', 'over the request cap');
237
+ if (tokens > budget.requestCapTokens) {
238
+ emit({ kind: 'refused', tokens });
239
+ throw new ContextOverflowError(
240
+ `The conversation no longer fits the model's context window (about ${tokens} of ${budget.windowTokens} tokens even after trimming old tool results). Start a new session or ask for a shorter answer.`,
241
+ );
242
+ }
243
+ try {
244
+ return await handler({ ...request, messages });
245
+ } catch (error) {
246
+ if (!isContextOverflowError(error)) throw error;
247
+ const learned = await options
248
+ .onOverflow?.(error)
249
+ .catch(() => undefined);
250
+ logger.warn(
251
+ `[context] provider rejected the request as too long (${learned ? `window now ${learned}` : 'no limit named'}); retrying once with a hard prune`,
252
+ );
253
+ emit({
254
+ kind: 'overflow-retry',
255
+ ...(learned !== undefined ? { learnedWindow: learned } : {}),
256
+ });
257
+ shrink(PRUNE_HARD, 'overflow', 'after the provider overflow');
258
+ try {
259
+ return await handler({ ...request, messages });
260
+ } catch (retryError) {
261
+ if (!isContextOverflowError(retryError)) throw retryError;
262
+ throw new ContextOverflowError(
263
+ "The conversation no longer fits the model's context window, even after trimming. Start a new session.",
264
+ retryError,
265
+ );
266
+ }
267
+ }
268
+ },
269
+ });
270
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Tool results larger than the turn's cap never reach the model whole.
3
+ *
4
+ * The cap is a fraction of the model's context window (`context-budget.ts`),
5
+ * so it scales with the model. Above it the result is stored whole (the
6
+ * result store: SQLite under ~1 MB, R2 beyond) and the model receives the
7
+ * first 40% and last 60% of the visible budget with a footer naming the
8
+ * handle, so `read_result` can page through the rest when needed. Nothing
9
+ * is decided per tool: every large result is both truncated and preserved.
10
+ *
11
+ * Applied to the main agent and every sub-agent (via `toolMiddlewares`), so
12
+ * a sub-agent's own tool calls are capped too — and the sub-agent's reply,
13
+ * which is a tool result to the main agent, is capped in turn.
14
+ */
15
+ import { ToolMessage } from '@langchain/core/messages';
16
+ import { type AgentMiddleware, createMiddleware } from 'langchain';
17
+ import type { Logger } from '../../plugin-api/types';
18
+ import type { StoredResultRef } from '../../do/result-store';
19
+ import { NOOP_LOGGER } from '../utils';
20
+
21
+ export interface ResultCapStore {
22
+ put(input: {
23
+ sessionId: string;
24
+ toolName: string;
25
+ content: string;
26
+ }): Promise<StoredResultRef | undefined>;
27
+ }
28
+
29
+ export interface ResultCapOptions {
30
+ /** Visible characters a result may have before it is capped. */
31
+ capChars: number;
32
+ sessionId: string;
33
+ store?: ResultCapStore;
34
+ /** Share of the visible budget given to the head (default 0.4, Hermes' split). */
35
+ headRatio?: number;
36
+ /** Tools whose results are never capped (`read_result` pages by design). */
37
+ exempt?: ReadonlySet<string>;
38
+ logger?: Logger;
39
+ }
40
+
41
+ /** The same configuration, as the tool wrapper takes it (see `wrapPluginTool`). */
42
+ export type ResultCapConfig = ResultCapOptions;
43
+
44
+ /**
45
+ * Cap one result's text: store it whole (when a store is bound) and return
46
+ * the head + tail + footer the model sees, or the text unchanged when it
47
+ * fits. Used at the tool boundary (`wrapPluginTool`, so the SSE frame, the
48
+ * model and the transcript all carry the capped text) and by the
49
+ * middleware below for tools that bypass the wrapper (sub-agents).
50
+ */
51
+ export async function capToolResult(
52
+ text: string,
53
+ toolName: string,
54
+ options: ResultCapOptions,
55
+ ): Promise<{ text: string; meta?: CappedResultMeta }> {
56
+ if (text.length <= options.capChars) return { text };
57
+ if (options.exempt?.has(toolName)) return { text };
58
+ const logger = options.logger ?? NOOP_LOGGER;
59
+ let ref: StoredResultRef | undefined;
60
+ try {
61
+ ref = await options.store?.put({
62
+ sessionId: options.sessionId,
63
+ toolName,
64
+ content: text,
65
+ });
66
+ } catch (error) {
67
+ logger.warn(
68
+ `[result-cap] ${toolName}: could not save the full result: ${error instanceof Error ? error.message : String(error)}`,
69
+ );
70
+ }
71
+ const meta: CappedResultMeta = {
72
+ ...(ref ? { id: ref.id } : {}),
73
+ size: text.length,
74
+ shown: Math.max(200, options.capChars - 400),
75
+ };
76
+ logger.log(
77
+ `[result-cap] ${toolName}: ${text.length} chars > cap ${options.capChars}; ${ref ? `saved as ${ref.id.slice(0, 12)}… (${ref.tier})` : 'not saved'}`,
78
+ );
79
+ return {
80
+ text: truncateHeadTail(
81
+ text,
82
+ options.capChars,
83
+ meta,
84
+ options.headRatio ?? 0.4,
85
+ ),
86
+ meta,
87
+ };
88
+ }
89
+
90
+ /** Marker kept in `additional_kwargs` so later passes know the result was capped. */
91
+ export interface CappedResultMeta {
92
+ id?: string;
93
+ size: number;
94
+ shown: number;
95
+ }
96
+
97
+ export const CAPPED_META_KEY = 'capped';
98
+
99
+ export function capFooter(
100
+ meta: CappedResultMeta,
101
+ head: number,
102
+ tail: number,
103
+ ): string {
104
+ const where = meta.id
105
+ ? `The full result is saved as ${meta.id}: call read_result({ id: "${meta.id}", offset: 0 }) and follow \`next\` to read it in chunks.`
106
+ : 'The full result could not be saved.';
107
+ return `\n\n[Result truncated: showing the first ${head} and last ${tail} of ${meta.size} characters. ${where}]`;
108
+ }
109
+
110
+ /** Split a visible budget into head and tail lengths. */
111
+ export function headTailSplit(
112
+ budget: number,
113
+ headRatio = 0.4,
114
+ ): { head: number; tail: number } {
115
+ const head = Math.max(0, Math.floor(budget * headRatio));
116
+ return { head, tail: Math.max(0, budget - head) };
117
+ }
118
+
119
+ export function truncateHeadTail(
120
+ text: string,
121
+ capChars: number,
122
+ meta: CappedResultMeta,
123
+ headRatio = 0.4,
124
+ ): string {
125
+ // Leave room for the footer inside the cap.
126
+ const budget = Math.max(200, capChars - 400);
127
+ const { head, tail } = headTailSplit(budget, headRatio);
128
+ return `${text.slice(0, head)}\n\n… [${text.length - head - tail} characters omitted] …\n\n${text.slice(text.length - tail)}${capFooter(meta, head, tail)}`;
129
+ }
130
+
131
+ function contentToText(content: unknown): string {
132
+ if (typeof content === 'string') return content;
133
+ try {
134
+ return JSON.stringify(content) ?? '';
135
+ } catch {
136
+ return String(content);
137
+ }
138
+ }
139
+
140
+ export function createResultCapMiddleware(
141
+ options: ResultCapOptions,
142
+ ): AgentMiddleware {
143
+ return createMiddleware({
144
+ name: 'ResultCapMiddleware',
145
+ wrapToolCall: async (request, handler) => {
146
+ const output = await handler(request);
147
+ if (!ToolMessage.isInstance(output)) return output;
148
+ const toolName = output.name ?? request.toolCall.name;
149
+ const capped = await capToolResult(
150
+ contentToText(output.content),
151
+ toolName,
152
+ options,
153
+ );
154
+ if (!capped.meta) return output;
155
+ return new ToolMessage({
156
+ ...(output.id ? { id: output.id } : {}),
157
+ tool_call_id: output.tool_call_id,
158
+ name: toolName,
159
+ status: output.status,
160
+ content: capped.text,
161
+ additional_kwargs: {
162
+ ...output.additional_kwargs,
163
+ [CAPPED_META_KEY]: capped.meta,
164
+ },
165
+ });
166
+ },
167
+ });
168
+ }
169
+
170
+ /** The cap marker of a tool message, if it was capped. */
171
+ export function cappedMetaOf(message: {
172
+ additional_kwargs?: Record<string, unknown>;
173
+ }): CappedResultMeta | undefined {
174
+ const raw = message.additional_kwargs?.[CAPPED_META_KEY];
175
+ if (!raw || typeof raw !== 'object') return undefined;
176
+ const meta = raw as Partial<CappedResultMeta>;
177
+ return typeof meta.size === 'number'
178
+ ? { ...meta, size: meta.size, shown: meta.shown ?? 0 }
179
+ : undefined;
180
+ }