@ixo/oracle-runtime-workers 0.38.0 → 0.40.1
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/package.json +5 -4
- package/src/action-interactions/routes.ts +78 -0
- package/src/action-interactions/store.ts +1038 -0
- package/src/channels/replies.ts +8 -4
- package/src/core/capability-expiry.ts +62 -0
- package/src/core/capability-router.ts +126 -95
- package/src/core/context-budget.ts +116 -4
- package/src/core/domain-context/document.ts +74 -2
- package/src/core/domain-context/index.ts +1 -0
- package/src/core/domain-context/resolver.ts +9 -6
- package/src/core/domain-context/turn-context.ts +62 -13
- package/src/core/domain-context/types.ts +17 -0
- package/src/core/env.ts +21 -2
- package/src/core/index.ts +4 -0
- package/src/core/llm.ts +29 -0
- package/src/core/main-agent-types.ts +45 -4
- package/src/core/main-agent.ts +210 -122
- package/src/core/manifest.ts +109 -24
- package/src/core/mcp-result.ts +56 -0
- package/src/core/meta-tools.ts +258 -110
- package/src/core/middlewares/capability-gate.ts +77 -28
- package/src/core/middlewares/context-guard.ts +154 -21
- package/src/core/middlewares/result-cap.ts +1 -1
- package/src/core/middlewares/summarization.ts +9 -0
- package/src/core/middlewares/tool-marks.ts +11 -2
- package/src/core/middlewares/tool-repetition-guard.ts +61 -1
- package/src/core/middlewares/turn-boundary.ts +22 -12
- package/src/core/operating-guides.ts +126 -0
- package/src/core/plugins/skills/skills-tools.ts +6 -31
- package/src/core/plugins/skills/skills.plugin.ts +4 -3
- package/src/core/plugins/weather/weather.plugin.ts +1 -0
- package/src/core/prompt-composer.ts +188 -111
- package/src/core/read-result-tool.ts +48 -14
- package/src/core/registries.ts +13 -5
- package/src/core/state.ts +4 -3
- package/src/core/sub-agent-fallback.ts +1 -0
- package/src/core/subagent-as-tool.ts +4 -1
- package/src/core/tool-access.ts +3 -32
- package/src/core/wrap-plugin-tool.ts +2 -1
- package/src/do/contracts.ts +45 -1
- package/src/do/transcript.ts +40 -11
- package/src/do/turn-metadata.ts +16 -4
- package/src/do/user-oracle-do.ts +358 -62
- package/src/index.ts +1 -0
- package/src/memory/session-history-indexer.ts +60 -14
- package/src/plugin-api/oracle-plugin.ts +4 -5
- package/src/plugin-api/types.ts +32 -3
- package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
- package/src/plugins/agui/agui.plugin.ts +1 -0
- package/src/plugins/compact-schema.ts +80 -0
- package/src/plugins/composio/composio.plugin.ts +1 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
- package/src/plugins/editor/editor.plugin.ts +4 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
- package/src/plugins/flows/flows.plugin.ts +37 -6
- package/src/plugins/flows/prompts.ts +1 -1
- package/src/plugins/flows/tools/authoring.ts +5 -7
- package/src/plugins/flows/tools/discovery.ts +1 -4
- package/src/plugins/flows/tools/forms.ts +2 -8
- package/src/plugins/flows/tools/linkage.ts +1 -4
- package/src/plugins/flows/tools/settings.ts +1 -4
- package/src/plugins/flows/translator.ts +16 -5
- package/src/plugins/flows/types.ts +27 -48
- package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
- package/src/plugins/index.ts +2 -2
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
- package/src/plugins/ixo-chain/kinds/core.ts +2 -2
- package/src/plugins/ixo-chain/kinds/types.ts +34 -5
- package/src/plugins/ixo-chain/queries.ts +10 -1
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
- package/src/plugins/matrix-group-chats/index.ts +1 -0
- package/src/plugins/memory/memory-tools.ts +4 -4
- package/src/plugins/memory/memory.plugin.ts +27 -1
- package/src/plugins/memory/oracle-document-tools.ts +24 -49
- package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
- package/src/plugins/portal/portal.plugin.ts +3 -1
- package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
- package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
- package/src/plugins/tasks/manifest.ts +1 -0
- package/src/plugins/tasks/tasks-tools.ts +34 -39
- package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
- package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
- package/src/plugins/vfs/vfs-tools.ts +6 -6
- package/src/plugins/vfs/vfs.plugin.ts +4 -5
- package/src/plugins/yoma/yoma.plugin.ts +1 -0
- package/src/shell/app.ts +241 -6
- package/src/sqlite/serialization.ts +12 -0
- package/src/sqlite/sqlite-saver.ts +17 -0
- package/src/tasks/schedule-input.ts +5 -22
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Keeps every model request inside the model's window.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Four layers, all on the request only (graph state and the transcript are
|
|
5
5
|
* never rewritten here):
|
|
6
6
|
*
|
|
7
|
-
* 1. **
|
|
8
|
-
* results
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* 1. **Stale results.** On a turn that opens after `cacheColdMs` of quiet,
|
|
8
|
+
* tool results older than the last `staleResultTurns` finished turns
|
|
9
|
+
* become one-line stubs naming how to read them back. Rewriting an old
|
|
10
|
+
* message costs a cache miss on every byte after it, and on some routes
|
|
11
|
+
* on the whole request, so the line only moves when the provider's
|
|
12
|
+
* cache has expired anyway: between two cold turns it stays put and the
|
|
13
|
+
* request keeps its prefix, call after call. The line is computed from
|
|
14
|
+
* the messages alone, so a resumed attempt draws the same one.
|
|
15
|
+
* 2. **Pressure pruning.** Above `pruneAtTokens`, tool results outside the
|
|
16
|
+
* kept tail are demoted to one-line placeholders (a capped result keeps
|
|
17
|
+
* its `read_result` handle) and results whose text is identical to a
|
|
18
|
+
* later one are replaced by a back-reference. No model call, no
|
|
19
|
+
* storage — Hermes' deterministic passes. The tail moves with every
|
|
20
|
+
* message, so past this threshold the cache is given up for size.
|
|
21
|
+
*
|
|
22
|
+
* Neither of the first two touches the load result of a plugin that is still
|
|
23
|
+
* loaded: it is how the model knows the plugin's rules (`operating-guides.ts`).
|
|
24
|
+
* 3. **The hard cap.** A request still above `requestCapTokens` after a
|
|
13
25
|
* harder prune is refused with a clear error instead of being sent to
|
|
14
26
|
* fail at the provider.
|
|
15
|
-
*
|
|
27
|
+
* 4. **Overflow recovery.** When the provider rejects the request anyway,
|
|
16
28
|
* the limit it names is learned (the window is lowered for good), the
|
|
17
29
|
* request is pruned hard, and it is retried once — only when the prune
|
|
18
30
|
* made it strictly smaller; an unchanged request is never sent twice.
|
|
@@ -34,8 +46,10 @@ import {
|
|
|
34
46
|
type ContextBudget,
|
|
35
47
|
} from '../context-budget';
|
|
36
48
|
import { isContextOverflowError } from '../context-window';
|
|
49
|
+
import { usageCarriedBy } from '../operating-guides';
|
|
37
50
|
import { NOOP_LOGGER } from '../utils';
|
|
38
51
|
import { cappedMetaOf } from './result-cap';
|
|
52
|
+
import { turnOpenings } from './turn-boundary';
|
|
39
53
|
|
|
40
54
|
export class ContextOverflowError extends Error {
|
|
41
55
|
readonly retryable = false;
|
|
@@ -57,7 +71,7 @@ export class ContextOverflowError extends Error {
|
|
|
57
71
|
export type ContextGuardEvent =
|
|
58
72
|
| {
|
|
59
73
|
kind: 'prune';
|
|
60
|
-
stage: 'soft' | 'hard' | 'overflow';
|
|
74
|
+
stage: 'stale' | 'soft' | 'hard' | 'overflow';
|
|
61
75
|
pruned: number;
|
|
62
76
|
beforeTokens: number;
|
|
63
77
|
afterTokens: number;
|
|
@@ -74,6 +88,11 @@ export interface ContextGuardOptions {
|
|
|
74
88
|
onOverflow?: (error: unknown) => Promise<number | undefined>;
|
|
75
89
|
/** Observes every prune, overflow retry and refusal (never throws into the turn). */
|
|
76
90
|
onEvent?: (event: ContextGuardEvent) => void;
|
|
91
|
+
/**
|
|
92
|
+
* The longest chunk `read_result` returns, when the tool is bound this
|
|
93
|
+
* turn: a stale-result stub then says how to read the result back.
|
|
94
|
+
*/
|
|
95
|
+
readResultChars?: number;
|
|
77
96
|
logger?: Logger;
|
|
78
97
|
}
|
|
79
98
|
|
|
@@ -98,15 +117,10 @@ function textOf(content: unknown): string {
|
|
|
98
117
|
|
|
99
118
|
function placeholder(
|
|
100
119
|
message: ToolMessage,
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
reason: 'pruned' | 'duplicate' | 'stale',
|
|
121
|
+
body: string,
|
|
103
122
|
): ToolMessage {
|
|
104
123
|
const name = message.name ?? 'tool';
|
|
105
|
-
const meta = cappedMetaOf(message);
|
|
106
|
-
const body =
|
|
107
|
-
reason === 'duplicate'
|
|
108
|
-
? `[${name} result: identical to a later ${name} result, omitted]`
|
|
109
|
-
: `[${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` : ''}]`;
|
|
110
124
|
return new ToolMessage({
|
|
111
125
|
...(message.id ? { id: message.id } : {}),
|
|
112
126
|
tool_call_id: message.tool_call_id,
|
|
@@ -117,6 +131,79 @@ function placeholder(
|
|
|
117
131
|
});
|
|
118
132
|
}
|
|
119
133
|
|
|
134
|
+
function prunedBody(message: ToolMessage, text: string): string {
|
|
135
|
+
const name = message.name ?? 'tool';
|
|
136
|
+
const meta = cappedMetaOf(message);
|
|
137
|
+
return `[${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` : ''}]`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** When the message that opens a turn was sent; `NaN` for one that does not say. */
|
|
141
|
+
function sentAt(message: BaseMessage | undefined): number {
|
|
142
|
+
const timestamp = message?.additional_kwargs?.timestamp;
|
|
143
|
+
return typeof timestamp === 'string' ? Date.parse(timestamp) : Number.NaN;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Index before which tool results are stale: the start of the turns kept
|
|
148
|
+
* whole on the latest cold turn. 0 when no cold turn has that many turns
|
|
149
|
+
* behind it. A turn is cold when it opens at least `coldGapMs` after the one
|
|
150
|
+
* before it. Pure over the messages: until the next cold turn every call
|
|
151
|
+
* finds the same index, so the request's prefix does not change.
|
|
152
|
+
*/
|
|
153
|
+
export function staleResultCutoff(
|
|
154
|
+
messages: readonly BaseMessage[],
|
|
155
|
+
keepTurns: number,
|
|
156
|
+
coldGapMs: number,
|
|
157
|
+
): number {
|
|
158
|
+
if (keepTurns <= 0) return 0;
|
|
159
|
+
const openings = turnOpenings(messages);
|
|
160
|
+
for (let turn = openings.length - 1; turn >= Math.max(keepTurns, 1); turn--) {
|
|
161
|
+
const opening = openings[turn];
|
|
162
|
+
const previous = openings[turn - 1];
|
|
163
|
+
if (opening === undefined || previous === undefined) continue;
|
|
164
|
+
if (sentAt(messages[opening]) - sentAt(messages[previous]) >= coldGapMs)
|
|
165
|
+
return openings[turn - keepTurns] ?? 0;
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Tool results before `cutoff` → stubs. Pure: new message objects with the
|
|
172
|
+
* same ids and the same text on every call, the input untouched.
|
|
173
|
+
*/
|
|
174
|
+
export function trimStaleResults(
|
|
175
|
+
messages: BaseMessage[],
|
|
176
|
+
cutoff: number,
|
|
177
|
+
options: {
|
|
178
|
+
demoteMinChars: number;
|
|
179
|
+
readResultChars?: number;
|
|
180
|
+
keep?: (message: ToolMessage) => boolean;
|
|
181
|
+
},
|
|
182
|
+
): { messages: BaseMessage[]; pruned: number } {
|
|
183
|
+
if (cutoff <= 0) return { messages, pruned: 0 };
|
|
184
|
+
const out = [...messages];
|
|
185
|
+
let pruned = 0;
|
|
186
|
+
for (let i = 0; i < cutoff; i += 1) {
|
|
187
|
+
const m = messages[i];
|
|
188
|
+
if (!m || !ToolMessage.isInstance(m) || options.keep?.(m)) continue;
|
|
189
|
+
const text = textOf(m.content);
|
|
190
|
+
if (text.length <= options.demoteMinChars) continue;
|
|
191
|
+
const name = m.name ?? 'tool';
|
|
192
|
+
const id = cappedMetaOf(m)?.id ?? m.tool_call_id;
|
|
193
|
+
const how =
|
|
194
|
+
options.readResultChars === undefined
|
|
195
|
+
? 'call the tool again if you need it'
|
|
196
|
+
: `read_result({ id: "${id}", length: ${options.readResultChars} }) reads it back`;
|
|
197
|
+
out[i] = placeholder(
|
|
198
|
+
m,
|
|
199
|
+
'stale',
|
|
200
|
+
`[${name} result from an earlier turn (${text.length} characters) removed from context; ${how}]`,
|
|
201
|
+
);
|
|
202
|
+
pruned += 1;
|
|
203
|
+
}
|
|
204
|
+
return { messages: out, pruned };
|
|
205
|
+
}
|
|
206
|
+
|
|
120
207
|
/**
|
|
121
208
|
* Old tool results → one-liners; duplicates → back-references. The last
|
|
122
209
|
* `keepTail` messages are returned as they are. Pure: new message objects
|
|
@@ -125,6 +212,7 @@ function placeholder(
|
|
|
125
212
|
export function pruneToolResults(
|
|
126
213
|
messages: BaseMessage[],
|
|
127
214
|
options: PruneOptions,
|
|
215
|
+
keep?: (message: ToolMessage) => boolean,
|
|
128
216
|
): { messages: BaseMessage[]; pruned: number } {
|
|
129
217
|
const cutoff = Math.max(0, messages.length - options.keepTail);
|
|
130
218
|
const out = [...messages];
|
|
@@ -134,20 +222,25 @@ export function pruneToolResults(
|
|
|
134
222
|
const seenLater = new Set<string>();
|
|
135
223
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
136
224
|
const m = messages[i]!;
|
|
137
|
-
if (!ToolMessage.isInstance(m)) continue;
|
|
225
|
+
if (!ToolMessage.isInstance(m) || keep?.(m)) continue;
|
|
138
226
|
const text = textOf(m.content);
|
|
139
227
|
if (i >= cutoff) {
|
|
140
228
|
if (text.length >= 40) seenLater.add(text);
|
|
141
229
|
continue;
|
|
142
230
|
}
|
|
143
231
|
if (text.length >= 40 && seenLater.has(text)) {
|
|
144
|
-
|
|
232
|
+
const name = m.name ?? 'tool';
|
|
233
|
+
out[i] = placeholder(
|
|
234
|
+
m,
|
|
235
|
+
'duplicate',
|
|
236
|
+
`[${name} result: identical to a later ${name} result, omitted]`,
|
|
237
|
+
);
|
|
145
238
|
pruned += 1;
|
|
146
239
|
continue;
|
|
147
240
|
}
|
|
148
241
|
if (text.length >= 40) seenLater.add(text);
|
|
149
242
|
if (text.length > options.demoteMinChars) {
|
|
150
|
-
out[i] = placeholder(m,
|
|
243
|
+
out[i] = placeholder(m, 'pruned', prunedBody(m, text));
|
|
151
244
|
pruned += 1;
|
|
152
245
|
}
|
|
153
246
|
}
|
|
@@ -213,6 +306,8 @@ export function createContextGuardMiddleware(
|
|
|
213
306
|
): AgentMiddleware {
|
|
214
307
|
const logger = options.logger ?? NOOP_LOGGER;
|
|
215
308
|
const { budget } = options;
|
|
309
|
+
// The guard lives for one turn and the stale line for many calls: one event.
|
|
310
|
+
let staleReported = false;
|
|
216
311
|
const emit = (event: ContextGuardEvent): void => {
|
|
217
312
|
try {
|
|
218
313
|
options.onEvent?.(event);
|
|
@@ -231,15 +326,53 @@ export function createContextGuardMiddleware(
|
|
|
231
326
|
const schemaTokens = schemaTokensOf(request.tools ?? []);
|
|
232
327
|
const size = (messages: BaseMessage[]) =>
|
|
233
328
|
estimateRequestTokens({ systemTokens, schemaTokens, messages });
|
|
234
|
-
|
|
329
|
+
const keep = (message: ToolMessage): boolean => {
|
|
330
|
+
const carried = usageCarriedBy(message);
|
|
331
|
+
if (carried.length === 0) return false;
|
|
332
|
+
const loaded: unknown = request.state.loadedPlugins;
|
|
333
|
+
return (
|
|
334
|
+
Array.isArray(loaded) &&
|
|
335
|
+
carried.some((plugin) => loaded.includes(plugin))
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
const stale = trimStaleResults(
|
|
339
|
+
request.messages,
|
|
340
|
+
staleResultCutoff(
|
|
341
|
+
request.messages,
|
|
342
|
+
budget.staleResultTurns,
|
|
343
|
+
budget.cacheColdMs,
|
|
344
|
+
),
|
|
345
|
+
{
|
|
346
|
+
demoteMinChars: PRUNE_SOFT.demoteMinChars,
|
|
347
|
+
keep,
|
|
348
|
+
...(options.readResultChars !== undefined
|
|
349
|
+
? { readResultChars: options.readResultChars }
|
|
350
|
+
: {}),
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
let messages = stale.messages;
|
|
235
354
|
let tokens = size(messages);
|
|
355
|
+
if (stale.pruned > 0 && !staleReported) {
|
|
356
|
+
staleReported = true;
|
|
357
|
+
const before = size(request.messages);
|
|
358
|
+
logger.log(
|
|
359
|
+
`[context] stale results: trimmed ${stale.pruned} tool result(s), ~${before} → ~${tokens} tokens`,
|
|
360
|
+
);
|
|
361
|
+
emit({
|
|
362
|
+
kind: 'prune',
|
|
363
|
+
stage: 'stale',
|
|
364
|
+
pruned: stale.pruned,
|
|
365
|
+
beforeTokens: before,
|
|
366
|
+
afterTokens: tokens,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
236
369
|
const shrink = (
|
|
237
370
|
opts: PruneOptions,
|
|
238
371
|
stage: 'soft' | 'hard' | 'overflow',
|
|
239
372
|
why: string,
|
|
240
373
|
): void => {
|
|
241
374
|
const before = tokens;
|
|
242
|
-
const result = pruneToolResults(messages, opts);
|
|
375
|
+
const result = pruneToolResults(messages, opts, keep);
|
|
243
376
|
messages = result.messages;
|
|
244
377
|
tokens = size(messages);
|
|
245
378
|
if (result.pruned > 0) {
|
|
@@ -128,7 +128,7 @@ export function truncateHeadTail(
|
|
|
128
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
129
|
}
|
|
130
130
|
|
|
131
|
-
function contentToText(content: unknown): string {
|
|
131
|
+
export function contentToText(content: unknown): string {
|
|
132
132
|
if (typeof content === 'string') return content;
|
|
133
133
|
try {
|
|
134
134
|
return JSON.stringify(content) ?? '';
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from '@langchain/core/messages';
|
|
8
8
|
import { type AgentMiddleware, summarizationMiddleware } from 'langchain';
|
|
9
9
|
import type { Logger } from '../../plugin-api/types';
|
|
10
|
+
import { usageCarriedBy } from '../operating-guides';
|
|
10
11
|
import type { TurnBudget } from '../turn-budget';
|
|
11
12
|
import {
|
|
12
13
|
carriedCallsOfTurn,
|
|
@@ -273,6 +274,14 @@ export const createSummarizationMiddleware = (
|
|
|
273
274
|
options.logger?.log(
|
|
274
275
|
`[summarization] condensed the history: ${state.messages.length} messages → a summary + ${messages.length - 2} kept`,
|
|
275
276
|
);
|
|
277
|
+
// A plugin's usage goes with the messages that carried it, and the next
|
|
278
|
+
// turn sends it again (operating-guides.ts).
|
|
279
|
+
return {
|
|
280
|
+
...update,
|
|
281
|
+
capabilityUsage: {
|
|
282
|
+
replace: messages.filter(isBaseMessageLike).flatMap(usageCarriedBy),
|
|
283
|
+
},
|
|
284
|
+
};
|
|
276
285
|
}
|
|
277
286
|
return update;
|
|
278
287
|
};
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
SystemMessage,
|
|
31
31
|
ToolMessage,
|
|
32
32
|
} from '@langchain/core/messages';
|
|
33
|
+
import { Command } from '@langchain/langgraph';
|
|
33
34
|
import { type AgentMiddleware, createMiddleware } from 'langchain';
|
|
34
35
|
import type { ToolEffect, ToolMark } from '../../do/run-store';
|
|
35
36
|
import type { Logger } from '../../plugin-api/types';
|
|
@@ -155,10 +156,18 @@ export function toolEffectOf(tool: {
|
|
|
155
156
|
return startsWithReadVerb(bare) ? 'read' : 'write';
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* Whether a tool call's output is an error result: a `ToolMessage` with status
|
|
161
|
+
* `error`, alone or inside a graph command's update (the capability gate's
|
|
162
|
+
* refusal, which also loads the capability).
|
|
163
|
+
*/
|
|
159
164
|
export function isErrorResult(output: unknown): boolean {
|
|
160
165
|
if (ToolMessage.isInstance(output)) return output.status === 'error';
|
|
161
|
-
return false;
|
|
166
|
+
if (!(output instanceof Command)) return false;
|
|
167
|
+
const { update } = output;
|
|
168
|
+
if (!update || Array.isArray(update) || !('messages' in update)) return false;
|
|
169
|
+
const { messages } = update;
|
|
170
|
+
return Array.isArray(messages) && messages.some(isErrorResult);
|
|
162
171
|
}
|
|
163
172
|
|
|
164
173
|
export function createToolMarksMiddleware(
|
|
@@ -20,6 +20,28 @@ export interface RepetitionCaps {
|
|
|
20
20
|
|
|
21
21
|
export const DEFAULT_REPETITION_CAPS: RepetitionCaps = { reads: 5, writes: 1 };
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Tools that only find other tools; a model alternating between them never trips the identical-call cap.
|
|
25
|
+
* `load_capability` is not one: it is how a gated tool becomes callable.
|
|
26
|
+
*/
|
|
27
|
+
export const DISCOVERY_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
28
|
+
'list_capabilities',
|
|
29
|
+
'list_skills',
|
|
30
|
+
'search_skills',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function discoveryCallsInTurn(messages: readonly BaseMessage[]): number {
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (let i = turnStart(messages); i < messages.length; i++) {
|
|
36
|
+
const msg = messages[i];
|
|
37
|
+
if (msg instanceof ToolMessage && DISCOVERY_TOOL_NAMES.has(msg.name ?? ''))
|
|
38
|
+
count += 1;
|
|
39
|
+
}
|
|
40
|
+
return count;
|
|
41
|
+
}
|
|
42
|
+
/** Discovery calls a turn may make, whatever their arguments. */
|
|
43
|
+
export const MAX_DISCOVERY_CALLS = 6;
|
|
44
|
+
|
|
23
45
|
/**
|
|
24
46
|
* Parse `TURN_MAX_IDENTICAL_READS` / `TURN_MAX_IDENTICAL_WRITES`; anything
|
|
25
47
|
* that is not a positive number keeps its default (as `turnLimitsFromEnv`).
|
|
@@ -99,6 +121,18 @@ export const createToolRepetitionGuardMiddleware = (
|
|
|
99
121
|
|
|
100
122
|
return createMiddleware({
|
|
101
123
|
name: 'ToolRepetitionGuardMiddleware',
|
|
124
|
+
// A model can keep calling a refused tool forever; once the turn spent its searches, it no longer sees them.
|
|
125
|
+
wrapModelCall: (request, handler) => {
|
|
126
|
+
if (
|
|
127
|
+
discoveryCallsInTurn(request.state.messages ?? []) < MAX_DISCOVERY_CALLS
|
|
128
|
+
)
|
|
129
|
+
return handler(request);
|
|
130
|
+
const tools = request.tools.filter(
|
|
131
|
+
(t) =>
|
|
132
|
+
!(typeof t.name === 'string' && DISCOVERY_TOOL_NAMES.has(t.name)),
|
|
133
|
+
);
|
|
134
|
+
return handler({ ...request, tools });
|
|
135
|
+
},
|
|
102
136
|
wrapToolCall: async (toolCallRequest, handler) => {
|
|
103
137
|
const { toolCall, state } = toolCallRequest;
|
|
104
138
|
const toolName = toolCall.name ?? toolCallRequest.tool?.name;
|
|
@@ -109,6 +143,26 @@ export const createToolRepetitionGuardMiddleware = (
|
|
|
109
143
|
const opening = turnStart(messages);
|
|
110
144
|
const start = Math.max(opening, messages.length - lookback, 0);
|
|
111
145
|
const argsById = toolCallArgsById(messages, start);
|
|
146
|
+
|
|
147
|
+
if (DISCOVERY_TOOL_NAMES.has(toolName)) {
|
|
148
|
+
const discovered = discoveryCallsInTurn(messages);
|
|
149
|
+
if (discovered >= MAX_DISCOVERY_CALLS) {
|
|
150
|
+
logger.warn(
|
|
151
|
+
`Repetition guard: ${toolName} refused after ${discovered} discovery calls this turn`,
|
|
152
|
+
);
|
|
153
|
+
return new ToolMessage({
|
|
154
|
+
content: [
|
|
155
|
+
`You have already searched capabilities and skills ${discovered} times in this turn, so \`${toolName}\` was NOT run.`,
|
|
156
|
+
'',
|
|
157
|
+
'Stop searching. Act with the tools you already have: read their descriptions and call the one that fits the request,',
|
|
158
|
+
'or tell the user what you could not find and ask them what they want.',
|
|
159
|
+
].join('\n'),
|
|
160
|
+
tool_call_id: toolCall.id ?? '',
|
|
161
|
+
name: toolName,
|
|
162
|
+
status: 'error',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
112
166
|
// The turn's calls a mid-turn summary condensed away (oldest first):
|
|
113
167
|
// only while the lookback still reaches the summary.
|
|
114
168
|
const openingMessage = messages[opening];
|
|
@@ -121,7 +175,13 @@ export const createToolRepetitionGuardMiddleware = (
|
|
|
121
175
|
call.name === toolName && canonicalArgsKey(call.args) === argsKey,
|
|
122
176
|
);
|
|
123
177
|
|
|
124
|
-
const failedInTurn = (
|
|
178
|
+
const failedInTurn = (failure: string): ToolMessage => {
|
|
179
|
+
// A refusal of this guard is not quoted inside the next one, or each repeat nests every earlier one.
|
|
180
|
+
const priorError = /was NOT (called|run) again|was NOT run\b/.test(
|
|
181
|
+
failure,
|
|
182
|
+
)
|
|
183
|
+
? `This guard already refused it: \`${toolName}\` reached its limit in this turn.`
|
|
184
|
+
: failure;
|
|
125
185
|
logger.warn(
|
|
126
186
|
`Repetition guard: short-circuiting duplicate failed call to ${toolName}`,
|
|
127
187
|
{ toolName, priorError },
|
|
@@ -90,23 +90,33 @@ export function turnCarryOf(
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
* A turn opens at a human message other than the ones the runtime writes
|
|
94
|
+
* itself, or at a summary that carries the calls of a turn it condensed
|
|
95
|
+
* mid-way.
|
|
96
96
|
*/
|
|
97
|
+
function opensTurn(message: BaseMessage | undefined): boolean {
|
|
98
|
+
if (!message || !HumanMessage.isInstance(message)) return false;
|
|
99
|
+
if (isSummarizationMessage(message))
|
|
100
|
+
return turnCarryOf(message) !== undefined;
|
|
101
|
+
return !isAttachmentViewMessage(message);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Index of the message that opens the current turn; 0 when there is none. */
|
|
97
105
|
export function turnStart(messages: readonly BaseMessage[]): number {
|
|
98
|
-
for (let i = messages.length - 1; i >= 0; i--)
|
|
99
|
-
|
|
100
|
-
if (!message || !HumanMessage.isInstance(message)) continue;
|
|
101
|
-
if (isSummarizationMessage(message)) {
|
|
102
|
-
if (turnCarryOf(message) !== undefined) return i;
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (!isAttachmentViewMessage(message)) return i;
|
|
106
|
-
}
|
|
106
|
+
for (let i = messages.length - 1; i >= 0; i--)
|
|
107
|
+
if (opensTurn(messages[i])) return i;
|
|
107
108
|
return 0;
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/** The indexes of every turn-opening message, oldest first. */
|
|
112
|
+
export function turnOpenings(messages: readonly BaseMessage[]): number[] {
|
|
113
|
+
const openings: number[] = [];
|
|
114
|
+
messages.forEach((message, index) => {
|
|
115
|
+
if (opensTurn(message)) openings.push(index);
|
|
116
|
+
});
|
|
117
|
+
return openings;
|
|
118
|
+
}
|
|
119
|
+
|
|
110
120
|
function resultText(message: ToolMessage): string {
|
|
111
121
|
const { content } = message;
|
|
112
122
|
const text =
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where what the model needs to use a loaded plugin reaches it: the plugin's
|
|
3
|
+
* usage (`summary`, `whenToUse`, `whenNotToUse`, `examples`, the tool names)
|
|
4
|
+
* and its operating guide.
|
|
5
|
+
*
|
|
6
|
+
* A guide in the system prompt is the cheapest place for it: cached like the
|
|
7
|
+
* rest of the prefix, it survives summaries and trimming. But any change to
|
|
8
|
+
* the system prompt costs a full cache miss on the next call, so the guides
|
|
9
|
+
* in the prompt only change on a cold turn, when that miss has already
|
|
10
|
+
* happened (`CONTEXT_CACHE_COLD_MS`). The set the prompt carries is recorded
|
|
11
|
+
* on the thread (the graph's `promptGuides` channel) and held until then.
|
|
12
|
+
*
|
|
13
|
+
* Usage, and a guide outside the prompt, go at the tail of the messages, which
|
|
14
|
+
* keeps the cache: in the result of the load that brought the plugin
|
|
15
|
+
* (`load_capability`, the capability gate), or as a note on the turn's message
|
|
16
|
+
* when nothing called a load (a router preload, the editor context) or a
|
|
17
|
+
* summary has condensed that result away. The thread records which plugins it
|
|
18
|
+
* still carries in the graph's `capabilityUsage` channel, so a turn needs no
|
|
19
|
+
* read of the messages to know, and the messages that carry them say so in
|
|
20
|
+
* `USAGE_CARRIED_KWARG`: the context guard keeps them while the plugin is
|
|
21
|
+
* loaded, and a summary keeps the record of the ones it did not condense.
|
|
22
|
+
*/
|
|
23
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
24
|
+
import { OPERATING_GUIDES_HEADING } from './prompt-composer';
|
|
25
|
+
|
|
26
|
+
/** `additional_kwargs` key listing the plugins whose usage a message carries. */
|
|
27
|
+
export const USAGE_CARRIED_KWARG = 'capability_usage';
|
|
28
|
+
|
|
29
|
+
/** `additional_kwargs` key holding the exact note a user message was sent with. */
|
|
30
|
+
export const TURN_GUIDE_NOTE_KWARG = 'turn_guide_note';
|
|
31
|
+
|
|
32
|
+
/** The plugins whose usage `message` carries. */
|
|
33
|
+
export function usageCarriedBy(message: BaseMessage): string[] {
|
|
34
|
+
const carried: unknown = message.additional_kwargs?.[USAGE_CARRIED_KWARG];
|
|
35
|
+
return Array.isArray(carried)
|
|
36
|
+
? carried.filter((name) => typeof name === 'string')
|
|
37
|
+
: [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The note a turn's message carries for plugins that have no load result in
|
|
42
|
+
* the thread: what `load_capability` would have returned for them.
|
|
43
|
+
*/
|
|
44
|
+
export function renderGuideNote(loads: readonly unknown[]): string {
|
|
45
|
+
return `[From the runtime, not the user: these capabilities are loaded and in your tool list.]\n\n${OPERATING_GUIDES_HEADING}\n\n${JSON.stringify(loads)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the thread records about its prompt, and whether the prompt may change. */
|
|
49
|
+
export interface PromptCacheState {
|
|
50
|
+
/** The provider's cache has expired (or never held this thread). */
|
|
51
|
+
cold: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The plugins whose guides the prompt carried on the thread's last turn.
|
|
54
|
+
* Undefined on a thread from before this was recorded, whose prompt carried
|
|
55
|
+
* every loaded plugin's guide.
|
|
56
|
+
*/
|
|
57
|
+
promptGuides?: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* The plugins whose usage the thread's messages carry. Undefined on a thread
|
|
60
|
+
* from before this was recorded, which counts as carrying what it loaded.
|
|
61
|
+
*/
|
|
62
|
+
capabilityUsage?: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface UsableGuide {
|
|
66
|
+
pluginName: string;
|
|
67
|
+
guide: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CapabilityPlan {
|
|
71
|
+
/** The guides of this turn's system prompt, in the order they were given. */
|
|
72
|
+
inPrompt: UsableGuide[];
|
|
73
|
+
/** Loaded plugins whose usage the thread does not carry: for the turn's message. */
|
|
74
|
+
onMessage: string[];
|
|
75
|
+
/** The new value of the `promptGuides` channel, when it is decided now. */
|
|
76
|
+
promptGuides?: string[];
|
|
77
|
+
/** The new value of the `capabilityUsage` channel, when it changes. */
|
|
78
|
+
capabilityUsage?: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sameMembers(a: readonly string[], b: ReadonlySet<string>): boolean {
|
|
82
|
+
return a.length === b.size && a.every((name) => b.has(name));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Decides where each plugin's guide and usage go this turn. `always` plugins
|
|
87
|
+
* have their guides in the prompt on every turn. Without a `cache` every turn
|
|
88
|
+
* counts as cold.
|
|
89
|
+
*/
|
|
90
|
+
export function planCapabilityContext(input: {
|
|
91
|
+
guides: readonly UsableGuide[];
|
|
92
|
+
/** The plugins loaded on the thread when the turn was built. */
|
|
93
|
+
loaded: ReadonlySet<string>;
|
|
94
|
+
/** The same, before this turn added to it. */
|
|
95
|
+
priorLoaded: ReadonlySet<string>;
|
|
96
|
+
/** Whether this delegation can use the plugin at all. */
|
|
97
|
+
usable: (pluginName: string) => boolean;
|
|
98
|
+
alwaysVisible: (pluginName: string) => boolean;
|
|
99
|
+
cache?: PromptCacheState;
|
|
100
|
+
}): CapabilityPlan {
|
|
101
|
+
const { guides, loaded, priorLoaded, usable, alwaysVisible, cache } = input;
|
|
102
|
+
const cold = !cache || cache.cold;
|
|
103
|
+
const recorded = cold ? loaded : new Set(cache.promptGuides ?? priorLoaded);
|
|
104
|
+
const carried = new Set(cache?.capabilityUsage ?? priorLoaded);
|
|
105
|
+
const onMessage = [...loaded].filter(
|
|
106
|
+
(pluginName) => usable(pluginName) && !carried.has(pluginName),
|
|
107
|
+
);
|
|
108
|
+
const capabilityUsage = new Set(
|
|
109
|
+
[...loaded].filter(
|
|
110
|
+
(pluginName) => carried.has(pluginName) || onMessage.includes(pluginName),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
inPrompt: guides.filter(
|
|
115
|
+
({ pluginName }) => alwaysVisible(pluginName) || recorded.has(pluginName),
|
|
116
|
+
),
|
|
117
|
+
onMessage,
|
|
118
|
+
...(cold || cache.promptGuides === undefined
|
|
119
|
+
? { promptGuides: [...recorded] }
|
|
120
|
+
: {}),
|
|
121
|
+
...(cache?.capabilityUsage !== undefined &&
|
|
122
|
+
sameMembers(cache.capabilityUsage, capabilityUsage)
|
|
123
|
+
? {}
|
|
124
|
+
: { capabilityUsage: [...capabilityUsage] }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -55,46 +55,21 @@ export interface MergedSkill {
|
|
|
55
55
|
createdAt?: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const LIST_DESCRIPTION = `List
|
|
58
|
+
const LIST_DESCRIPTION = `List skills: the caller's published private skills first, then the public IXO registry. Each entry has title, description, path (absolute sandbox path of the skill folder), source ("private" | "public") and cid. load_skill and sandbox_run take the cid; never use it as a file path.`;
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
- title: skill name
|
|
62
|
-
- description: skill description
|
|
63
|
-
- path: absolute sandbox path to the skill folder
|
|
64
|
-
- source: "private" (your published skill) or "public" (registry)
|
|
65
|
-
- cid: required by load_skill and by sandbox_run (when the run depends on the skill). Never use a CID as a file path.`;
|
|
66
|
-
|
|
67
|
-
const SEARCH_DESCRIPTION = `Search the caller's published skills and the public IXO registry by query.
|
|
68
|
-
|
|
69
|
-
Matching published (private) skills come first, then public registry results. Each entry includes title, description, path, source ("private" | "public"), and cid.`;
|
|
60
|
+
const SEARCH_DESCRIPTION = `Search the caller's published skills, then the public IXO registry. Entries as in list_skills. Running a skill takes load_skill and sandbox_run, from the sandbox capability.`;
|
|
70
61
|
|
|
71
62
|
const listSchema = z.object({
|
|
72
|
-
limit: z
|
|
73
|
-
|
|
74
|
-
.min(1)
|
|
75
|
-
.max(100)
|
|
76
|
-
.optional()
|
|
77
|
-
.describe('Optional: number of skills to return (1-100, default: 20).'),
|
|
78
|
-
offset: z
|
|
79
|
-
.number()
|
|
80
|
-
.min(0)
|
|
81
|
-
.optional()
|
|
82
|
-
.describe('Optional: pagination offset (default: 0).'),
|
|
63
|
+
limit: z.number().min(1).max(100).optional().describe('Default 20.'),
|
|
64
|
+
offset: z.number().min(0).optional(),
|
|
83
65
|
});
|
|
84
66
|
|
|
85
67
|
const searchSchema = z.object({
|
|
86
68
|
q: z
|
|
87
69
|
.string()
|
|
88
70
|
.min(1, 'Search query is required')
|
|
89
|
-
.describe(
|
|
90
|
-
|
|
91
|
-
),
|
|
92
|
-
limit: z
|
|
93
|
-
.number()
|
|
94
|
-
.min(1)
|
|
95
|
-
.max(50)
|
|
96
|
-
.optional()
|
|
97
|
-
.describe('Optional: max results to return (1-50, default: 10).'),
|
|
71
|
+
.describe('E.g. "pptx", "invoice".'),
|
|
72
|
+
limit: z.number().min(1).max(50).optional().describe('Default 10.'),
|
|
98
73
|
});
|
|
99
74
|
|
|
100
75
|
function normalizeRegistryCapsule(capsule: Capsule): MergedSkill {
|
|
@@ -33,9 +33,7 @@ const manifest: PluginManifest = {
|
|
|
33
33
|
'User asks "what skills are available?" or "what can you do?".',
|
|
34
34
|
'User asks the agent to find a skill for a specific task ("a skill for invoices", "is there a skill for KYC?").',
|
|
35
35
|
],
|
|
36
|
-
whenNotToUse: [
|
|
37
|
-
'Executing a skill — this runtime only lists and searches the registry.',
|
|
38
|
-
],
|
|
36
|
+
whenNotToUse: ['Running a skill: that takes the `sandbox` capability.'],
|
|
39
37
|
examples: [
|
|
40
38
|
{
|
|
41
39
|
user: 'Do you have a skill that can generate an invoice?',
|
|
@@ -46,6 +44,9 @@ const manifest: PluginManifest = {
|
|
|
46
44
|
tags: ['skills', 'capsules', 'registry', 'ucan'],
|
|
47
45
|
category: 'data',
|
|
48
46
|
visibility: 'always',
|
|
47
|
+
brief: 'browse every skill, without a search term',
|
|
48
|
+
// `search_skills` stays bound; listing the whole registry is rare.
|
|
49
|
+
onDemandTools: ['list_skills'],
|
|
49
50
|
stability: 'stable',
|
|
50
51
|
};
|
|
51
52
|
|