@ixo/oracle-runtime-workers 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/package.json +2 -2
  2. package/src/channels/replies.ts +8 -4
  3. package/src/core/capability-expiry.ts +62 -0
  4. package/src/core/capability-router.ts +126 -95
  5. package/src/core/context-budget.ts +116 -4
  6. package/src/core/domain-context/document.ts +74 -2
  7. package/src/core/domain-context/index.ts +1 -0
  8. package/src/core/domain-context/resolver.ts +9 -6
  9. package/src/core/domain-context/turn-context.ts +62 -13
  10. package/src/core/domain-context/types.ts +17 -0
  11. package/src/core/env.ts +21 -2
  12. package/src/core/index.ts +4 -0
  13. package/src/core/llm.ts +29 -0
  14. package/src/core/main-agent-types.ts +45 -4
  15. package/src/core/main-agent.ts +210 -122
  16. package/src/core/manifest.ts +109 -24
  17. package/src/core/mcp-result.ts +56 -0
  18. package/src/core/meta-tools.ts +258 -110
  19. package/src/core/middlewares/capability-gate.ts +77 -28
  20. package/src/core/middlewares/context-guard.ts +154 -21
  21. package/src/core/middlewares/result-cap.ts +1 -1
  22. package/src/core/middlewares/summarization.ts +9 -0
  23. package/src/core/middlewares/tool-marks.ts +11 -2
  24. package/src/core/middlewares/turn-boundary.ts +22 -12
  25. package/src/core/operating-guides.ts +126 -0
  26. package/src/core/plugins/skills/skills-tools.ts +6 -31
  27. package/src/core/plugins/skills/skills.plugin.ts +4 -3
  28. package/src/core/plugins/weather/weather.plugin.ts +1 -0
  29. package/src/core/prompt-composer.ts +188 -111
  30. package/src/core/read-result-tool.ts +48 -14
  31. package/src/core/registries.ts +13 -5
  32. package/src/core/state.ts +4 -3
  33. package/src/core/sub-agent-fallback.ts +1 -0
  34. package/src/core/subagent-as-tool.ts +4 -1
  35. package/src/core/tool-access.ts +3 -32
  36. package/src/core/wrap-plugin-tool.ts +2 -1
  37. package/src/do/contracts.ts +13 -1
  38. package/src/do/transcript.ts +40 -11
  39. package/src/do/turn-metadata.ts +16 -4
  40. package/src/do/user-oracle-do.ts +132 -59
  41. package/src/index.ts +1 -0
  42. package/src/memory/session-history-indexer.ts +60 -14
  43. package/src/plugin-api/oracle-plugin.ts +4 -5
  44. package/src/plugin-api/types.ts +32 -3
  45. package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
  46. package/src/plugins/agui/agui.plugin.ts +1 -0
  47. package/src/plugins/compact-schema.ts +80 -0
  48. package/src/plugins/composio/composio.plugin.ts +1 -0
  49. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
  50. package/src/plugins/editor/editor.plugin.ts +4 -0
  51. package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
  52. package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
  53. package/src/plugins/flows/flows.plugin.ts +37 -6
  54. package/src/plugins/flows/prompts.ts +1 -1
  55. package/src/plugins/flows/tools/authoring.ts +5 -7
  56. package/src/plugins/flows/tools/discovery.ts +1 -4
  57. package/src/plugins/flows/tools/forms.ts +2 -8
  58. package/src/plugins/flows/tools/linkage.ts +1 -4
  59. package/src/plugins/flows/tools/settings.ts +1 -4
  60. package/src/plugins/flows/translator.ts +16 -5
  61. package/src/plugins/flows/types.ts +27 -48
  62. package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
  63. package/src/plugins/index.ts +2 -2
  64. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
  65. package/src/plugins/ixo-chain/kinds/core.ts +2 -2
  66. package/src/plugins/ixo-chain/kinds/types.ts +34 -5
  67. package/src/plugins/ixo-chain/queries.ts +10 -1
  68. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
  69. package/src/plugins/matrix-group-chats/index.ts +1 -0
  70. package/src/plugins/memory/memory-tools.ts +4 -4
  71. package/src/plugins/memory/memory.plugin.ts +27 -1
  72. package/src/plugins/memory/oracle-document-tools.ts +24 -49
  73. package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
  74. package/src/plugins/portal/portal.plugin.ts +1 -0
  75. package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
  76. package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
  77. package/src/plugins/tasks/manifest.ts +1 -0
  78. package/src/plugins/tasks/tasks-tools.ts +14 -19
  79. package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
  80. package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
  81. package/src/plugins/vfs/vfs-tools.ts +6 -6
  82. package/src/plugins/vfs/vfs.plugin.ts +4 -5
  83. package/src/plugins/yoma/yoma.plugin.ts +1 -0
  84. package/src/sqlite/serialization.ts +12 -0
  85. package/src/sqlite/sqlite-saver.ts +17 -0
  86. package/src/tasks/schedule-input.ts +5 -22
@@ -67,7 +67,7 @@ export interface ComposePromptInput {
67
67
  * by loaded capabilities. Empty string renders no section.
68
68
  */
69
69
  customInstructions: string;
70
- /** Operational-mode block — typically a multi-line string. */
70
+ /** Operational-mode block (`hooks.operationalMode`). Empty renders no section. */
71
71
  operationalMode: string;
72
72
  /**
73
73
  * Whether the discovery meta-tools (`search_skills`, `list_capabilities`,
@@ -78,8 +78,8 @@ export interface ComposePromptInput {
78
78
  capabilityDiscovery?: boolean;
79
79
  /**
80
80
  * Browser tools the client declared for this turn (`state.browserTools`),
81
- * by name, plus whether they are already callable: `bound` is true when the
82
- * portal plugin is `always` visible or has been loaded on this thread.
81
+ * by name, plus whether they are always callable: `bound` is true when the
82
+ * portal plugin is `always` visible.
83
83
  * Omit (or pass empty `names`) on non-Portal turns — nothing renders.
84
84
  */
85
85
  browserTools?: { names: string[]; bound: boolean };
@@ -135,12 +135,41 @@ export const CONTEXT_SECTION_LABELS: Record<ContextSlot, string> = {
135
135
  };
136
136
 
137
137
  // ── Memory-context rendering ────────────────────────────────────────────────
138
- // Guardrail caps for the block. Cross-bucket dedup does the real shrinking;
139
- // these only fire on pathological inputs so one entity/section can't dominate.
138
+ // Guardrail caps for the block. Cross-bucket dedup does the first shrinking;
139
+ // the budget then decides what goes in (`fitContextLines`).
140
140
  const COMPACT_ENTITY_SUMMARY_CAP = 700;
141
141
  const COMPACT_BLOCK_BUDGET = 2000;
142
142
  const COMPACT_OVERFLOW_NOTE = '_(More remembered — ask me to recall.)_';
143
143
 
144
+ /** The lines of one memory bucket, by kind. */
145
+ interface SlotLines {
146
+ entities: string[];
147
+ facts: string[];
148
+ episodes: string[];
149
+ communities: string[];
150
+ }
151
+
152
+ /** How a bucket reads, kind by kind. */
153
+ const KIND_LABELS: ReadonlyArray<[keyof SlotLines, string]> = [
154
+ ['entities', '_Key entities:_'],
155
+ ['facts', '_Facts:_'],
156
+ ['episodes', '_Episodes (raw):_'],
157
+ ['communities', '_Topic clusters:_'],
158
+ ];
159
+
160
+ /**
161
+ * Who gets the budget first. A fact says one thing in one line. A raw episode
162
+ * is what a fact was extracted from, or a question that was never one, so it
163
+ * only takes room nothing else wants: the answer follows the block, and a
164
+ * fact behind the episodes of earlier buckets is a fact the model never reads.
165
+ */
166
+ const KIND_PRIORITY: ReadonlyArray<keyof SlotLines> = [
167
+ 'facts',
168
+ 'entities',
169
+ 'communities',
170
+ 'episodes',
171
+ ];
172
+
144
173
  /**
145
174
  * Normalize a line for duplicate detection: lowercase, strip surrounding
146
175
  * punctuation and collapse whitespace. Intentionally conservative — it only
@@ -186,92 +215,117 @@ function capAtBoundary(text: string, max: number): string {
186
215
  }
187
216
 
188
217
  /**
189
- * Render one memory-context bucket. Dedups entities (by name) and facts (by
190
- * normalized text) against a `seen` set shared across every bucket, using the
191
- * richest summary collected for each entity.
218
+ * One bucket's lines. Dedups entities (by name) and facts (by normalized text)
219
+ * against a `seen` set shared across every bucket, using the richest summary
220
+ * collected for each entity. Facts are looked at first, in every bucket, so a
221
+ * fact is never lost to the line of an entity that may not fit.
192
222
  */
193
- function formatContextSectionCompact(
223
+ function collectFacts(
194
224
  data: MemoryContextSection | undefined,
195
225
  seen: Set<string>,
196
- richestSummary: Map<string, string>,
197
- ): string | null {
198
- if (!data) return null;
199
- const { entities, facts, episodes, communities } = data;
226
+ ): string[] {
200
227
  const lines: string[] = [];
228
+ for (const f of data?.facts ?? []) {
229
+ const text = f.fact?.trim();
230
+ if (!text) continue;
231
+ const key = normalizeForDedup(text);
232
+ if (seen.has(key)) continue;
233
+ seen.add(key);
234
+ lines.push(`- ${text}`);
235
+ }
236
+ return lines;
237
+ }
201
238
 
202
- if (entities?.length) {
203
- const entityLines: string[] = [];
204
- for (const e of entities) {
205
- const nameKey = `entity:${normalizeForDedup(e.name)}`;
206
- if (seen.has(nameKey)) continue;
207
- seen.add(nameKey);
208
- const labels = (e.labels ?? []).filter((l) => l !== 'Entity').join('/');
209
- const tag = labels ? ` (${labels})` : '';
210
- const rawSummary = richestSummary.get(normalizeForDedup(e.name)) ?? '';
211
- const summaryParts = rawSummary
212
- ? dedupSummaryLines(rawSummary, seen)
213
- : [];
214
- const summary = summaryParts.length
215
- ? capAtBoundary(summaryParts.join('; '), COMPACT_ENTITY_SUMMARY_CAP)
216
- : '';
217
- entityLines.push(
218
- summary ? `- **${e.name}**${tag}: ${summary}` : `- **${e.name}**${tag}`,
219
- );
220
- }
221
- if (entityLines.length) lines.push('_Key entities:_', ...entityLines);
239
+ function collectOthers(
240
+ data: MemoryContextSection | undefined,
241
+ seen: Set<string>,
242
+ richestSummary: Map<string, string>,
243
+ ): Omit<SlotLines, 'facts'> {
244
+ const entities: string[] = [];
245
+ for (const e of data?.entities ?? []) {
246
+ const nameKey = `entity:${normalizeForDedup(e.name)}`;
247
+ if (seen.has(nameKey)) continue;
248
+ seen.add(nameKey);
249
+ const labels = (e.labels ?? []).filter((l) => l !== 'Entity').join('/');
250
+ const tag = labels ? ` (${labels})` : '';
251
+ const rawSummary = richestSummary.get(normalizeForDedup(e.name)) ?? '';
252
+ const summaryParts = rawSummary ? dedupSummaryLines(rawSummary, seen) : [];
253
+ const summary = summaryParts.length
254
+ ? capAtBoundary(summaryParts.join('; '), COMPACT_ENTITY_SUMMARY_CAP)
255
+ : '';
256
+ entities.push(
257
+ summary ? `- **${e.name}**${tag}: ${summary}` : `- **${e.name}**${tag}`,
258
+ );
222
259
  }
223
260
 
224
- if (facts?.length) {
225
- const factLines: string[] = [];
226
- for (const f of facts) {
227
- const text = f.fact?.trim();
228
- if (!text) continue;
229
- const key = normalizeForDedup(text);
230
- if (seen.has(key)) continue;
231
- seen.add(key);
232
- factLines.push(`- ${text}`);
233
- }
234
- if (factLines.length) {
235
- if (lines.length) lines.push('');
236
- lines.push('_Facts:_', ...factLines);
237
- }
261
+ const episodes: string[] = [];
262
+ for (const ep of data?.episodes ?? []) {
263
+ const content = ep.content?.trim();
264
+ if (!content) continue;
265
+ const key = normalizeForDedup(content);
266
+ if (seen.has(key)) continue;
267
+ seen.add(key);
268
+ const date = ep.created_at?.slice(0, 10) ?? '';
269
+ episodes.push(date ? `- *${date}* — ${content}` : `- ${content}`);
238
270
  }
239
271
 
240
- if (episodes?.length) {
241
- const epLines: string[] = [];
242
- for (const ep of episodes) {
243
- const content = ep.content?.trim();
244
- if (!content) continue;
245
- const key = normalizeForDedup(content);
246
- if (seen.has(key)) continue;
247
- seen.add(key);
248
- const date = ep.created_at?.slice(0, 10) ?? '';
249
- epLines.push(date ? `- *${date}* — ${content}` : `- ${content}`);
250
- }
251
- if (epLines.length) {
252
- if (lines.length) lines.push('');
253
- lines.push('_Episodes (raw):_', ...epLines);
254
- }
272
+ const communities: string[] = [];
273
+ for (const c of data?.communities ?? []) {
274
+ const nameKey = `community:${normalizeForDedup(c.name)}`;
275
+ if (seen.has(nameKey)) continue;
276
+ seen.add(nameKey);
277
+ const summary = c.summary?.trim();
278
+ communities.push(
279
+ summary ? `- **${c.name}**: ${summary}` : `- **${c.name}**`,
280
+ );
255
281
  }
282
+ return { entities, episodes, communities };
283
+ }
256
284
 
257
- if (communities?.length) {
258
- const commLines: string[] = [];
259
- for (const c of communities) {
260
- const nameKey = `community:${normalizeForDedup(c.name)}`;
261
- if (seen.has(nameKey)) continue;
262
- seen.add(nameKey);
263
- const summary = c.summary?.trim();
264
- commLines.push(
265
- summary ? `- **${c.name}**: ${summary}` : `- **${c.name}**`,
285
+ /** The block as the model reads it, empty buckets and kinds left out. */
286
+ function renderContextSlots(
287
+ slots: ReadonlyMap<ContextSlot, SlotLines>,
288
+ ): string {
289
+ const sections: string[] = [];
290
+ for (const [key, lines] of slots) {
291
+ const kinds = KIND_LABELS.filter(([kind]) => lines[kind].length > 0).map(
292
+ ([kind, label]) => [label, ...lines[kind]].join('\n'),
293
+ );
294
+ if (kinds.length > 0)
295
+ sections.push(
296
+ `**${CONTEXT_SECTION_LABELS[key]}**\n${kinds.join('\n\n')}`,
266
297
  );
267
- }
268
- if (commLines.length) {
269
- if (lines.length) lines.push('');
270
- lines.push('_Topic clusters:_', ...commLines);
271
- }
272
298
  }
299
+ return sections.join('\n\n');
300
+ }
273
301
 
274
- return lines.length ? lines.join('\n') : null;
302
+ /**
303
+ * Fills the budget kind by kind in `KIND_PRIORITY` order, and within a kind one
304
+ * line from every bucket in turn, so no bucket waits behind an earlier one's
305
+ * list. A line that does not fit is passed over for a shorter one after it.
306
+ * The same input always keeps the same lines: the block sits in the system
307
+ * prompt, which has to stay byte-identical between turns.
308
+ */
309
+ function fitContextLines(
310
+ slots: ReadonlyMap<ContextSlot, SlotLines>,
311
+ ): ReadonlyMap<ContextSlot, SlotLines> {
312
+ const kept = new Map<ContextSlot, SlotLines>();
313
+ for (const key of slots.keys())
314
+ kept.set(key, { entities: [], facts: [], episodes: [], communities: [] });
315
+ for (const kind of KIND_PRIORITY) {
316
+ const longest = Math.max(...[...slots.values()].map((s) => s[kind].length));
317
+ for (let line = 0; line < longest; line += 1) {
318
+ for (const [key, lines] of slots) {
319
+ const candidate = lines[kind][line];
320
+ const target = kept.get(key)?.[kind];
321
+ if (candidate === undefined || !target) continue;
322
+ target.push(candidate);
323
+ if (renderContextSlots(kept).length > COMPACT_BLOCK_BUDGET)
324
+ target.pop();
325
+ }
326
+ }
327
+ }
328
+ return kept;
275
329
  }
276
330
 
277
331
  /**
@@ -296,28 +350,31 @@ function collectRichestSummaries(typed: TypedUserContext): Map<string, string> {
296
350
 
297
351
  /**
298
352
  * Render the memory-context block — empty string when nothing is populated.
299
- * Cross-bucket dedup + guardrail budgeting are always applied.
353
+ * Cross-bucket dedup is always applied; past the budget, `fitContextLines`
354
+ * decides what stays and the note says that more is remembered.
300
355
  */
301
356
  function buildContextBlock(userContext: UserContextData | undefined): string {
302
357
  if (!userContext) return '';
303
358
  const typed = userContext as TypedUserContext;
359
+ const keys = Object.keys(CONTEXT_SECTION_LABELS) as ContextSlot[];
304
360
 
305
361
  const seen = new Set<string>();
362
+ const facts = new Map(
363
+ keys.map((key) => [key, collectFacts(typed[key], seen)]),
364
+ );
306
365
  const richestSummary = collectRichestSummaries(typed);
307
- const sections: string[] = [];
308
- for (const key of Object.keys(CONTEXT_SECTION_LABELS) as ContextSlot[]) {
309
- const formatted = formatContextSectionCompact(
310
- typed[key],
311
- seen,
312
- richestSummary,
313
- );
314
- if (formatted) {
315
- sections.push(`**${CONTEXT_SECTION_LABELS[key]}**\n${formatted}`);
316
- }
317
- }
318
- const block = sections.join('\n\n');
366
+ const slots = new Map<ContextSlot, SlotLines>(
367
+ keys.map((key) => [
368
+ key,
369
+ {
370
+ facts: facts.get(key) ?? [],
371
+ ...collectOthers(typed[key], seen, richestSummary),
372
+ },
373
+ ]),
374
+ );
375
+ const block = renderContextSlots(slots);
319
376
  if (block.length <= COMPACT_BLOCK_BUDGET) return block;
320
- return `${capAtBoundary(block, COMPACT_BLOCK_BUDGET)}\n\n${COMPACT_OVERFLOW_NOTE}`;
377
+ return `${renderContextSlots(fitContextLines(slots))}\n\n${COMPACT_OVERFLOW_NOTE}`;
321
378
  }
322
379
 
323
380
  /** Render user preferences as a bullet list for the prompt. */
@@ -371,8 +428,8 @@ export const PORTAL_CAPABILITY = 'portal';
371
428
  /**
372
429
  * Render the per-turn "Browser tools this turn" section. Empty string when the
373
430
  * client declared no browser tools, so non-Portal turns pay nothing for it.
374
- * The load line appears only while the tools sit behind the capability gate
375
- * (portal is `on-demand` and not yet in `loadedPlugins`).
431
+ * The load line depends on the plugin's visibility and not on whether the
432
+ * thread loaded it: a loaded capability must not change the system prompt.
376
433
  */
377
434
  function buildBrowserToolsBlock(
378
435
  browserTools: ComposePromptInput['browserTools'],
@@ -385,28 +442,42 @@ function buildBrowserToolsBlock(
385
442
  ];
386
443
  if (!browserTools.bound) {
387
444
  lines.push(
388
- `They are bound behind the \`${PORTAL_CAPABILITY}\` capability — call \`load_capability({ names: ['${PORTAL_CAPABILITY}'] })\` once before using them.`,
445
+ `Unless they are in your tool list, call \`load_capability({ names: ['${PORTAL_CAPABILITY}'] })\` once before using them.`,
389
446
  );
390
447
  }
391
448
  return lines.join('\n');
392
449
  }
393
450
 
394
451
  /**
395
- * The discovery mandate. Rendered on every turn whose surface can actually
396
- * grow — i.e. wherever the meta-tools are bound.
452
+ * How to reach what is not in the tool list. Rendered on every turn whose
453
+ * surface can actually grow, i.e. wherever the meta-tools are bound. The
454
+ * catalogue in the capability block names what can be loaded, so reaching
455
+ * something takes one `load_capability`, and work the bound tools cover takes
456
+ * no discovery at all.
397
457
  */
398
- const DISCOVERY_PRINCIPLE = `- **Search first, build second.** Before doing any non-trivial task, you MUST run discovery before producing the answer yourself. Capabilities live in three lanes, each found differently:
399
- 1. **Packaged skills** — call \`search_skills\` against the user's request whenever a skill could plausibly do the job (generating a file, document, report, or page; calculations; lookups; multi-step workflows).
400
- 2. **Server-side plugins and third-party integrations** (memory, sandbox, email, calendar, web, files, external services) — call \`list_capabilities\` to scan loaded + on-demand plugins, then call \`load_capability({ names: [...] })\` ONCE with everything you need — it accepts an array, so never make multiple \`load_capability\` calls in the same turn.
401
- 3. **Browser tools** — the \`[Portal]\`-prefixed tools already in your tool list. They run on the user's screen inside the Portal: navigate, open a URL or page, create pages/rooms, fill forms, grant access, change what the user sees. Use them for anything that happens on the user's side. They NEVER appear in \`search_skills\` or \`list_capabilities\` results — check your tool list (and the "Browser tools this turn" section when present) directly.
402
- Do this proactively — even when the task wording doesn't literally match a capability's \`whenToUse\`. Tell the user in ONE short sentence what you're checking ("Checking the skills registry for an invoice generator…") before the search call, not after. If something fits, use it (load + run). If nothing fits after the search, say so in one short sentence and only THEN build from scratch. Reusing a vetted capability is almost always better than reinventing it; silently skipping discovery is the worst failure mode.`;
458
+ const DISCOVERY_PRINCIPLE = `- **Use your tool list first.** Tools already in it are ready: call them directly and never load them. Answer directly when the request needs no tool. When it needs something your tools cannot do, or a tool failed, reach it, in this order:
459
+ 1. **Capabilities** — pick from "On-demand capabilities" and call \`load_capability\` once with everything you need. Do this rather than guess or say you cannot: remembered or assumed data is no substitute for a capability that has the real answer or can do the job.
460
+ 2. **Skills** — before generating a file, document, report or page, or starting a packaged workflow, call \`search_skills\`. Running a skill takes the \`sandbox\` capability. If nothing fits, build it yourself.
461
+ 3. **Browser tools** — the \`[Portal]\`-prefixed tools act on the user's screen inside the Portal: navigate, open a page, create pages or rooms, fill forms, grant access. They never appear in \`search_skills\` or \`list_capabilities\`: check your tool list and the "Browser tools this turn" section.`;
403
462
 
404
463
  /**
405
- * The system-prompt template. Identical to the Node runtime's minus the
406
- * slots whose producers are not part of the Workers runtime: the editor
464
+ * The sentence that shows a person something is happening while tools run. It
465
+ * goes out with the tool call, in the same model response. A chat surface has
466
+ * a typing indicator and its own rule against narrating, so it stays out there.
467
+ */
468
+ const STATUS_LINE = `- When a request needs a tool, open your response with one short sentence saying what you are doing ("Checking your balance."), in the same response as the tool call. Once per turn, and never for a reply that needs no tool.`;
469
+
470
+ /**
471
+ * The system-prompt template: the sections of the Node runtime's, minus the
472
+ * slots whose producers are not part of the Workers runtime (the editor
407
473
  * section, the Composio guidance block, Slack formatting constraints, the
408
474
  * commerce overlay, the sandbox user-secrets list, and the "Working with
409
- * files" section (it documents the Node `process_file` tool).
475
+ * files" section, which documents the Node `process_file` tool).
476
+ *
477
+ * Measured on `gpt-5.6-luna` (Bedrock and OpenAI routes): a change anywhere
478
+ * in the system prompt or the tool list, first line or last, drops the whole
479
+ * cached prefix. So what matters is not where a section sits but that the
480
+ * prompt stays byte-identical from call to call while the cache is warm.
410
481
  */
411
482
  const TEMPLATE = `{{{ORACLE_SECTION}}}
412
483
 
@@ -424,17 +495,20 @@ const TEMPLATE = `{{{ORACLE_SECTION}}}
424
495
  {{/BROWSER_TOOLS_BLOCK}}
425
496
  ## Operating principles
426
497
 
427
- - The user's current message is your primary instruction. Background context (what you already know about them) is for adapting tone and suggestions, not for overriding their intent.
498
+ - The user's current message is your primary instruction. Background context adapts tone and suggestions; it never overrides their intent.
428
499
  {{#DISCOVERY_PRINCIPLE}}
429
500
  {{{DISCOVERY_PRINCIPLE}}}
430
501
  {{/DISCOVERY_PRINCIPLE}}
431
- - Being proactive does **not** mean charging ahead blind. Pause and ask the user a short clarifying question when: (a) the request has multiple plausible interpretations and picking wrong would waste their time, (b) a capability scan surfaces several equally-good fits and you can't tell which they want, (c) you're about to take an irreversible or costly action (deleting data, sending a message, publishing, spending tokens on a long job), or (d) a required input is missing and you'd have to guess. One clarifying question beats five minutes of wrong work — but don't ask for things you can reasonably infer from context.
502
+ - Ask one short clarifying question when the request has several plausible readings and a wrong pick would waste their time, when several capabilities fit equally and you can't tell which they want, before an irreversible or costly action (deleting data, sending a message, publishing, spending tokens on a long job), or when a required input is missing and you'd have to guess. Don't ask for what you can reasonably infer: a timezone comes from the current time on the user's message. When the user tells you not to ask, decide what you can and say what you decided.
503
+ {{#STATUS_LINE}}
504
+ {{{STATUS_LINE}}}
505
+ {{/STATUS_LINE}}
432
506
  - When a tool or sub-agent succeeds, report the result. Never refuse after a successful tool call — including for credentials, tokens, identity data, or block/document operations.
433
- - The runtime retries a transient failure of a read-only tool once by itself. When a tool or sub-agent still fails, surface the failure to the user and ask how to proceed. Never repeat a write whose outcome is unknown without first verifying what happened, and never claim an authorization you were not given.
434
- - When delegating to a sub-agent, give it scope, intent, and the context it needs — sub-agents have no access to your conversation history.
507
+ - A transient failure of a read-only tool is retried for you. When a tool or sub-agent still fails, correct the call if its error says what to change; otherwise tell the user and ask how to proceed, and do not answer from memory or a guess in its place. Never repeat a write whose outcome is unknown without first verifying what happened, and never claim an authorization you were not given.
508
+ - Sub-agents have no access to your conversation history: give them scope, intent and the context they need.
435
509
  - Match the user's communication style. Be concise. Use Unicode emoji directly (\`🔥\`), never text shortcodes (\`:fire:\`).
436
- - When a capability turns out to be a strong fit for the user's recurring work, surface it explicitly and — if memory tools are available — offer to save the pattern for next time. Don't save silently; ask first.
437
- - Complete the user's request and stop. Don't add unrequested follow-on steps. "Checking capabilities first" is part of doing the request, not a follow-on.
510
+ - When a capability fits the user's recurring work, say so and, if memory tools are available, offer to save the pattern. Never save silently.
511
+ - Complete the user's request and stop. Don't add unrequested follow-on steps.
438
512
  {{#COMMUNICATION_STYLE}}
439
513
 
440
514
  {{{COMMUNICATION_STYLE}}}
@@ -473,10 +547,12 @@ _Background the memory engine recalled about this user — context to adapt to,
473
547
 
474
548
  {{{USER_PREFERENCES_CONTEXT}}}
475
549
  {{/USER_PREFERENCES_CONTEXT}}
550
+ {{#OPERATIONAL_MODE}}
476
551
 
477
552
  ## Operational mode
478
553
 
479
554
  {{{OPERATIONAL_MODE}}}
555
+ {{/OPERATIONAL_MODE}}
480
556
  `;
481
557
 
482
558
  const PROMPT_TEMPLATE = compileTemplate(TEMPLATE);
@@ -489,7 +565,7 @@ const PROMPT_TEMPLATE = compileTemplate(TEMPLATE);
489
565
  * - **context** — only populated memory sub-sections render; empty ones skipped.
490
566
  * - **user identity** — after the date and current entity: the user's DID
491
567
  * and IXO account address, and the oracle's own DIDs.
492
- * - **operational mode** — host hook or the runtime default.
568
+ * - **operational mode** — host hook; none by default.
493
569
  *
494
570
  * Synchronous (the Node version was async only because LangChain's
495
571
  * `PromptTemplate.format` is); kept `Promise`-returning for call-site parity.
@@ -519,6 +595,7 @@ export async function composePrompt(
519
595
  CUSTOM_INSTRUCTIONS: input.customInstructions,
520
596
  COMMUNICATION_STYLE: communicationStyle,
521
597
  SURFACE_BLOCK: input.surfaceBlock ?? '',
598
+ STATUS_LINE: input.surfaceBlock ? '' : STATUS_LINE,
522
599
  CONTEXT_BLOCK: buildContextBlock(input.userContext),
523
600
  TIME_CONTEXT: input.timeContext,
524
601
  CURRENT_ENTITY_DID: input.currentEntityDid,
@@ -1,13 +1,18 @@
1
1
  /**
2
- * `read_result` — pages through a tool result that was too large for the
3
- * context (`result-cap.ts` saved it whole and showed the model a head, a
4
- * tail and the handle). Chunks are byte ranges; `next` is the offset of the
2
+ * `read_result` — pages through a tool result the model no longer has in
3
+ * full: one too large for the context (`result-cap.ts` saved it whole and
4
+ * showed a head, a tail and the handle) or one trimmed from an earlier turn
5
+ * (`context-guard.ts` left a stub naming the tool call, and the message
6
+ * itself is still in the thread's state). Chunks of a saved result are byte
7
+ * ranges, of a trimmed one character ranges; `next` is the offset of the
5
8
  * following chunk or `null` at the end.
6
9
  */
10
+ import { ToolMessage, type BaseMessage } from '@langchain/core/messages';
7
11
  import { z } from 'zod';
8
12
  import { tool } from '../plugin-api/tool-helper';
9
13
  import type { PluginTool } from '../plugin-api/types';
10
14
  import type { ReadResultOutcome } from '../do/result-store';
15
+ import { contentToText } from './middlewares/result-cap';
11
16
 
12
17
  export interface ReadResultToolOptions {
13
18
  read: (
@@ -21,6 +26,27 @@ export interface ReadResultToolOptions {
21
26
 
22
27
  export const READ_RESULT_TOOL_NAME = 'read_result';
23
28
 
29
+ /** The longest chunk `read_result` hands back under a result cap: well under the cap, so a page is never capped itself. */
30
+ export function readResultMaxChars(resultCapChars: number): number {
31
+ return Math.min(16_000, Math.max(512, Math.floor(resultCapChars / 2)));
32
+ }
33
+
34
+ /**
35
+ * The text of the thread's tool result for call `id`, if it is still there.
36
+ * Newest first: a provider can use a call id again on a later turn.
37
+ */
38
+ function threadResult(
39
+ messages: readonly BaseMessage[],
40
+ id: string,
41
+ ): string | undefined {
42
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
43
+ const message = messages[i];
44
+ if (ToolMessage.isInstance(message) && message.tool_call_id === id)
45
+ return contentToText(message.content);
46
+ }
47
+ return undefined;
48
+ }
49
+
24
50
  export function buildReadResultTool(
25
51
  options: ReadResultToolOptions,
26
52
  ): PluginTool {
@@ -29,25 +55,33 @@ export function buildReadResultTool(
29
55
  const schema = z.object({
30
56
  id: z
31
57
  .string()
32
- .regex(/^[a-f0-9]{64}$/)
33
- .describe('The saved result id from a "[Result truncated …]" footer.'),
34
- offset: z
35
- .number()
36
- .int()
37
- .min(0)
38
- .default(0)
39
- .describe('Byte offset to read from.'),
58
+ .min(1)
59
+ .max(256)
60
+ .describe('The id named in a "[Result truncated …]" footer or stub.'),
61
+ offset: z.number().int().min(0).default(0).describe('Offset to read from.'),
40
62
  length: z
41
63
  .number()
42
64
  .int()
43
65
  .min(256)
44
66
  .max(maxChars)
45
67
  .default(defaultChars)
46
- .describe(`Bytes to read (max ${maxChars}).`),
68
+ .describe(`Length to read (max ${maxChars}).`),
47
69
  });
48
70
  return tool(
49
- async (rawArgs) => {
71
+ async (rawArgs, ctx) => {
50
72
  const { id, offset, length } = schema.parse(rawArgs);
73
+ const text = threadResult(ctx.history.messages, id);
74
+ if (text !== undefined) {
75
+ const end = offset + length;
76
+ return {
77
+ id,
78
+ offset,
79
+ length: Math.max(0, Math.min(end, text.length) - offset),
80
+ size: text.length,
81
+ next: end < text.length ? end : null,
82
+ text: text.slice(offset, end),
83
+ };
84
+ }
51
85
  const outcome = await options.read(id, offset, length);
52
86
  if (outcome.status !== 'ok')
53
87
  return {
@@ -70,7 +104,7 @@ export function buildReadResultTool(
70
104
  effect: 'read',
71
105
  visibility: 'always',
72
106
  description:
73
- 'Read a saved large tool result in chunks. Use the id from a "[Result truncated …]" footer; start at offset 0 and pass the returned `next` as the offset of the following call until `next` is null.',
107
+ 'Read a tool result you no longer have in full, in chunks. Use the id from a "[Result truncated …]" footer or a stub that says the result was removed; start at offset 0 and pass the returned `next` as the offset of the following call until `next` is null.',
74
108
  schema,
75
109
  },
76
110
  );
@@ -90,7 +90,7 @@ export interface ToolSummary {
90
90
  export interface ToolSummarySource {
91
91
  toolSummariesForPlugin(
92
92
  pluginName: string,
93
- ): Array<{ name: string; description: string }>;
93
+ ): Array<Pick<PluginTool, 'name' | 'description' | 'visibility'>>;
94
94
  }
95
95
 
96
96
  /**
@@ -98,7 +98,10 @@ export interface ToolSummarySource {
98
98
  * collection order — never another turn's.
99
99
  */
100
100
  export function turnToolSummaries(
101
- tools: ReadonlyArray<{ pluginName: string; tool: PluginTool }>,
101
+ tools: ReadonlyArray<{
102
+ pluginName: string;
103
+ tool: Pick<PluginTool, 'name' | 'description' | 'visibility'>;
104
+ }>,
102
105
  ): ToolSummarySource {
103
106
  return {
104
107
  toolSummariesForPlugin: (pluginName) =>
@@ -107,6 +110,7 @@ export function turnToolSummaries(
107
110
  .map(({ tool }) => ({
108
111
  name: tool.name,
109
112
  description: tool.description,
113
+ visibility: tool.visibility,
110
114
  })),
111
115
  };
112
116
  }
@@ -250,10 +254,14 @@ export class ToolRegistry {
250
254
  */
251
255
  toolSummariesForPlugin(
252
256
  pluginName: string,
253
- ): Array<{ name: string; description: string }> {
254
- return this.summaries()
257
+ ): Array<Pick<PluginTool, 'name' | 'description' | 'visibility'>> {
258
+ return (this.bootCache ?? [])
255
259
  .filter((entry) => entry.pluginName === pluginName)
256
- .map(({ name, description }) => ({ name, description }));
260
+ .map(({ tool: { name, description, visibility } }) => ({
261
+ name,
262
+ description,
263
+ visibility,
264
+ }));
257
265
  }
258
266
 
259
267
  /**
package/src/core/state.ts CHANGED
@@ -99,11 +99,12 @@ export const MainAgentGraphState = Annotation.Root({
99
99
 
100
100
  /**
101
101
  * Names of plugins the agent has loaded for this thread via
102
- * `load_capability`. Set-union reducer: plugins are added, never removed.
102
+ * `load_capability`. The channel the graph runs on, with its reducer, is
103
+ * declared by the capability gate (`capabilityGateState`); an idle plugin
104
+ * leaves it on a cold turn (`capability-expiry.ts`).
103
105
  */
104
106
  loadedPlugins: Annotation<string[]>({
105
- reducer: (current, update) =>
106
- Array.from(new Set([...(current ?? []), ...(update ?? [])])),
107
+ reducer: (_, update) => update,
107
108
  default: () => [],
108
109
  }),
109
110
  });
@@ -143,6 +143,7 @@ function defaultToAgentSpec(
143
143
  return {
144
144
  name: subAgent.name,
145
145
  description: subAgent.description,
146
+ ...(subAgent.failureHint ? { failureHint: subAgent.failureHint } : {}),
146
147
  systemPrompt,
147
148
  tools,
148
149
  model,
@@ -33,6 +33,8 @@ import { NOOP_LOGGER } from './utils';
33
33
  export interface AgentSpec {
34
34
  name: string;
35
35
  description: string;
36
+ /** Added to the error the parent reads when the sub-agent could not run. */
37
+ failureHint?: string;
36
38
  tools?: StructuredTool[];
37
39
  passthroughTools?: StructuredTool[];
38
40
  systemPrompt: string;
@@ -356,7 +358,8 @@ export function createSubagentAsTool(
356
358
  // to the parent as its result.
357
359
  if (config.signal?.aborted || isHarnessLimitError(err)) throw err;
358
360
  const message = err instanceof Error ? err.message : String(err);
359
- return `Error running ${spec.name}: ${message}`;
361
+ const hint = spec.failureHint ? ` ${spec.failureHint}` : '';
362
+ return `Error running ${spec.name}: ${message}${hint}`;
360
363
  }
361
364
  },
362
365
  {