@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.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/core/context-budget.ts +201 -0
- package/src/core/context-window.ts +289 -0
- package/src/core/env.ts +29 -0
- package/src/core/main-agent-types.ts +46 -0
- package/src/core/main-agent.ts +86 -9
- package/src/core/middlewares/context-guard.ts +270 -0
- package/src/core/middlewares/result-cap.ts +180 -0
- package/src/core/middlewares/summarization.ts +87 -6
- package/src/core/middlewares/tool-marks.ts +209 -0
- package/src/core/openrouter-pricing.ts +42 -1
- package/src/core/read-result-tool.ts +77 -0
- package/src/core/runtime-context.ts +7 -0
- package/src/core/sub-agent-fallback.ts +20 -3
- package/src/core/wrap-plugin-tool.ts +24 -3
- package/src/do/ambient.ts +17 -0
- package/src/do/contracts.ts +227 -0
- package/src/do/result-store-test-do.ts +88 -0
- package/src/do/result-store.ts +339 -0
- package/src/do/run-buffer.ts +204 -0
- package/src/do/run-coordinator.ts +572 -0
- package/src/do/run-request.ts +52 -0
- package/src/do/run-store-test-do.ts +130 -0
- package/src/do/run-store.ts +604 -0
- package/src/do/session-id-migration.ts +85 -0
- package/src/do/speaker-prefix.ts +37 -0
- package/src/do/sse-stream.ts +567 -400
- package/src/do/transcript.ts +238 -0
- package/src/do/turn-body.ts +10 -0
- package/src/do/user-oracle-do.ts +875 -146
- package/src/matrix/gateway-do.ts +230 -29
- package/src/matrix/group-chat-summarizer.ts +107 -0
- package/src/matrix/group-chat.ts +1163 -0
- package/src/matrix/inbox-store.ts +23 -3
- package/src/matrix/ingest.ts +54 -11
- package/src/matrix/reply-chain.ts +34 -25
- package/src/plugin-api/tool-helper.ts +9 -1
- package/src/plugin-api/types.ts +86 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/matrix-group-chats/README.md +70 -0
- package/src/plugins/matrix-group-chats/index.ts +132 -0
- package/src/plugins/matrix-group-chats/tools.ts +172 -0
- package/src/plugins/tasks/tasks-tools.ts +4 -0
- package/src/shell/app.ts +66 -0
- package/src/sqlite/sqlite-saver.ts +81 -0
- package/src/tasks/scheduler.ts +134 -3
- package/src/tasks/store.ts +22 -0
package/src/core/main-agent.ts
CHANGED
|
@@ -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 =
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
316
|
-
//
|
|
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
|
|
326
|
-
//
|
|
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'
|
|
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
|
+
}
|