@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.
Files changed (90) hide show
  1. package/package.json +5 -4
  2. package/src/action-interactions/routes.ts +78 -0
  3. package/src/action-interactions/store.ts +1038 -0
  4. package/src/channels/replies.ts +8 -4
  5. package/src/core/capability-expiry.ts +62 -0
  6. package/src/core/capability-router.ts +126 -95
  7. package/src/core/context-budget.ts +116 -4
  8. package/src/core/domain-context/document.ts +74 -2
  9. package/src/core/domain-context/index.ts +1 -0
  10. package/src/core/domain-context/resolver.ts +9 -6
  11. package/src/core/domain-context/turn-context.ts +62 -13
  12. package/src/core/domain-context/types.ts +17 -0
  13. package/src/core/env.ts +21 -2
  14. package/src/core/index.ts +4 -0
  15. package/src/core/llm.ts +29 -0
  16. package/src/core/main-agent-types.ts +45 -4
  17. package/src/core/main-agent.ts +210 -122
  18. package/src/core/manifest.ts +109 -24
  19. package/src/core/mcp-result.ts +56 -0
  20. package/src/core/meta-tools.ts +258 -110
  21. package/src/core/middlewares/capability-gate.ts +77 -28
  22. package/src/core/middlewares/context-guard.ts +154 -21
  23. package/src/core/middlewares/result-cap.ts +1 -1
  24. package/src/core/middlewares/summarization.ts +9 -0
  25. package/src/core/middlewares/tool-marks.ts +11 -2
  26. package/src/core/middlewares/tool-repetition-guard.ts +61 -1
  27. package/src/core/middlewares/turn-boundary.ts +22 -12
  28. package/src/core/operating-guides.ts +126 -0
  29. package/src/core/plugins/skills/skills-tools.ts +6 -31
  30. package/src/core/plugins/skills/skills.plugin.ts +4 -3
  31. package/src/core/plugins/weather/weather.plugin.ts +1 -0
  32. package/src/core/prompt-composer.ts +188 -111
  33. package/src/core/read-result-tool.ts +48 -14
  34. package/src/core/registries.ts +13 -5
  35. package/src/core/state.ts +4 -3
  36. package/src/core/sub-agent-fallback.ts +1 -0
  37. package/src/core/subagent-as-tool.ts +4 -1
  38. package/src/core/tool-access.ts +3 -32
  39. package/src/core/wrap-plugin-tool.ts +2 -1
  40. package/src/do/contracts.ts +45 -1
  41. package/src/do/transcript.ts +40 -11
  42. package/src/do/turn-metadata.ts +16 -4
  43. package/src/do/user-oracle-do.ts +358 -62
  44. package/src/index.ts +1 -0
  45. package/src/memory/session-history-indexer.ts +60 -14
  46. package/src/plugin-api/oracle-plugin.ts +4 -5
  47. package/src/plugin-api/types.ts +32 -3
  48. package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
  49. package/src/plugins/agui/agui.plugin.ts +1 -0
  50. package/src/plugins/compact-schema.ts +80 -0
  51. package/src/plugins/composio/composio.plugin.ts +1 -0
  52. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
  53. package/src/plugins/editor/editor.plugin.ts +4 -0
  54. package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
  55. package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
  56. package/src/plugins/flows/flows.plugin.ts +37 -6
  57. package/src/plugins/flows/prompts.ts +1 -1
  58. package/src/plugins/flows/tools/authoring.ts +5 -7
  59. package/src/plugins/flows/tools/discovery.ts +1 -4
  60. package/src/plugins/flows/tools/forms.ts +2 -8
  61. package/src/plugins/flows/tools/linkage.ts +1 -4
  62. package/src/plugins/flows/tools/settings.ts +1 -4
  63. package/src/plugins/flows/translator.ts +16 -5
  64. package/src/plugins/flows/types.ts +27 -48
  65. package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
  66. package/src/plugins/index.ts +2 -2
  67. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
  68. package/src/plugins/ixo-chain/kinds/core.ts +2 -2
  69. package/src/plugins/ixo-chain/kinds/types.ts +34 -5
  70. package/src/plugins/ixo-chain/queries.ts +10 -1
  71. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
  72. package/src/plugins/matrix-group-chats/index.ts +1 -0
  73. package/src/plugins/memory/memory-tools.ts +4 -4
  74. package/src/plugins/memory/memory.plugin.ts +27 -1
  75. package/src/plugins/memory/oracle-document-tools.ts +24 -49
  76. package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
  77. package/src/plugins/portal/portal.plugin.ts +3 -1
  78. package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
  79. package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
  80. package/src/plugins/tasks/manifest.ts +1 -0
  81. package/src/plugins/tasks/tasks-tools.ts +34 -39
  82. package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
  83. package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
  84. package/src/plugins/vfs/vfs-tools.ts +6 -6
  85. package/src/plugins/vfs/vfs.plugin.ts +4 -5
  86. package/src/plugins/yoma/yoma.plugin.ts +1 -0
  87. package/src/shell/app.ts +241 -6
  88. package/src/sqlite/serialization.ts +12 -0
  89. package/src/sqlite/sqlite-saver.ts +17 -0
  90. 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
- * Three layers, all on the request only (graph state and the transcript are
4
+ * Four layers, all on the request only (graph state and the transcript are
5
5
  * never rewritten here):
6
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
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
- * 3. **Overflow recovery.** When the provider rejects the request anyway,
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
- text: string,
102
- reason: 'pruned' | 'duplicate',
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
- out[i] = placeholder(m, text, 'duplicate');
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, text, 'pruned');
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
- let messages = request.messages;
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
- /** Whether a tool call's output is an error result (`ToolMessage` status `error`). */
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 = (priorError: string): ToolMessage => {
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
- * Index of the message that opens the current turn: the latest human
94
- * message other than the ones the runtime writes itself, or a summary that
95
- * carries the calls of a turn it condensed mid-way. 0 when there is none.
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
- const message = messages[i];
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 available skills from the IXO skills registry — the caller's **published** private skills first, then public registry skills.
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
- Each entry includes:
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
- .number()
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
- 'Search query (e.g. "pptx", "invoice", "presentation", "docx"). Required.',
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