@ixo/oracle-runtime-workers 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +38 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +249 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +1019 -169
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/owner-store/ixo-vfs-store.ts +12 -5
  39. package/src/owner-store/matrix-media-store.ts +12 -7
  40. package/src/owner-store/measure.ts +39 -0
  41. package/src/owner-store/migrating-store.ts +9 -3
  42. package/src/owner-store/types.ts +14 -4
  43. package/src/plugin-api/tool-helper.ts +9 -1
  44. package/src/plugin-api/types.ts +86 -0
  45. package/src/plugins/index.ts +3 -0
  46. package/src/plugins/matrix-group-chats/README.md +70 -0
  47. package/src/plugins/matrix-group-chats/index.ts +132 -0
  48. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  49. package/src/plugins/tasks/tasks-tools.ts +4 -0
  50. package/src/shell/app.ts +78 -0
  51. package/src/sqlite/cache-config.ts +27 -6
  52. package/src/sqlite/chunk-layout.ts +14 -0
  53. package/src/sqlite/database.ts +188 -19
  54. package/src/sqlite/do-vfs.ts +963 -99
  55. package/src/sqlite/index.ts +13 -0
  56. package/src/sqlite/page-tier.ts +668 -0
  57. package/src/sqlite/sqlite-saver.ts +81 -0
  58. package/src/tasks/scheduler.ts +134 -3
  59. package/src/tasks/store.ts +22 -0
@@ -1,6 +1,7 @@
1
1
  import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
2
2
  import type { BaseMessage } from '@langchain/core/messages';
3
3
  import { type AgentMiddleware, summarizationMiddleware } from 'langchain';
4
+ import type { Logger } from '../../plugin-api/types';
4
5
 
5
6
  /**
6
7
  * IXO-flavoured prompt instructing the summarizer to preserve identifiers
@@ -56,15 +57,41 @@ const DEFAULT_KEEP_MESSAGES = 10;
56
57
  export interface SummarizationMiddlewareOptions {
57
58
  /** Model used to generate the summary (typically a small/cheap router model). */
58
59
  model: BaseChatModel;
59
- /** Override the trigger threshold for messages (default: 20). */
60
- triggerMessages?: number;
61
60
  /**
62
- * Also trigger on approximate token count (default: 40k). Message-count
63
- * triggers alone miss threads whose few messages carry huge tool outputs.
61
+ * Trigger threshold in messages. Default 20 when no `triggerTokens` is
62
+ * given (the legacy pair); `null` disables the message trigger, which is
63
+ * what a window-derived `triggerTokens` wants (a tool turn is ~4 messages,
64
+ * so 20 fires after five turns whatever the model's window).
65
+ */
66
+ triggerMessages?: number | null;
67
+ /**
68
+ * Trigger on approximate token count (chars/4). Default 40k; the main
69
+ * agent passes a fraction of the model's context window.
64
70
  */
65
71
  triggerTokens?: number;
66
72
  /** Override the number of recent messages to keep (default: 10). */
67
73
  keepMessages?: number;
74
+ /** What the summarizer itself may read (tokens); default LangChain's. */
75
+ summaryInputTokens?: number;
76
+ logger?: Pick<Logger, 'warn' | 'log'>;
77
+ }
78
+
79
+ /**
80
+ * LangChain's summarizer swallows a failed summary call and returns
81
+ * `Error generating summary: …` as the summary text — which would then
82
+ * REPLACE the conversation history with an error string (the model's next
83
+ * reply reads "I only received an error in the conversation summary").
84
+ */
85
+ const FAILED_SUMMARY = /^Error generating summary:/;
86
+
87
+ export function isFailedSummary(message: BaseMessage): boolean {
88
+ if (!isSummarizationMessage(message)) return false;
89
+ const { content } = message;
90
+ if (typeof content !== 'string') return false;
91
+ const body = content.startsWith(SUMMARY_PREFIX)
92
+ ? content.slice(SUMMARY_PREFIX.length).trimStart()
93
+ : content;
94
+ return FAILED_SUMMARY.test(body);
68
95
  }
69
96
 
70
97
  /**
@@ -86,14 +113,68 @@ export function isSummarizationMessage(message: BaseMessage): boolean {
86
113
  export const createSummarizationMiddleware = (
87
114
  options: SummarizationMiddlewareOptions,
88
115
  ): AgentMiddleware => {
89
- return summarizationMiddleware({
116
+ const triggerMessages =
117
+ options.triggerMessages === null
118
+ ? null
119
+ : (options.triggerMessages ??
120
+ (options.triggerTokens === undefined
121
+ ? DEFAULT_TRIGGER_MESSAGES
122
+ : null));
123
+ const inner = summarizationMiddleware({
90
124
  model: options.model,
91
125
  summaryPrompt: SUMMARY_PROMPT,
92
126
  summaryPrefix: SUMMARY_PREFIX,
93
127
  trigger: [
94
- { messages: options.triggerMessages ?? DEFAULT_TRIGGER_MESSAGES },
128
+ ...(triggerMessages !== null ? [{ messages: triggerMessages }] : []),
95
129
  { tokens: options.triggerTokens ?? DEFAULT_TRIGGER_TOKENS },
96
130
  ],
97
131
  keep: { messages: options.keepMessages ?? DEFAULT_KEEP_MESSAGES },
132
+ ...(options.summaryInputTokens !== undefined
133
+ ? { trimTokensToSummarize: options.summaryInputTokens }
134
+ : {}),
98
135
  });
136
+ const beforeModel = inner.beforeModel;
137
+ if (!beforeModel) return inner;
138
+ // The hook is either a bare handler or `{ hook, canJumpTo }`; keep the shape.
139
+ const handler =
140
+ typeof beforeModel === 'function' ? beforeModel : beforeModel.hook;
141
+ // A summary that failed keeps the history exactly as it was: the turn
142
+ // runs on the full context and the next turn tries again.
143
+ const guarded: typeof handler = async (state, runtime) => {
144
+ const update = await handler(state, runtime);
145
+ const messages =
146
+ update && typeof update === 'object' && 'messages' in update
147
+ ? (update as { messages?: unknown }).messages
148
+ : undefined;
149
+ const failed = Array.isArray(messages)
150
+ ? messages.find((m) => isBaseMessageLike(m) && isFailedSummary(m))
151
+ : undefined;
152
+ if (failed) {
153
+ options.logger?.warn(
154
+ `[summarization] summary failed; keeping the full history this turn: ${String(failed.content).slice(0, 300)}`,
155
+ );
156
+ return undefined;
157
+ }
158
+ if (Array.isArray(messages) && messages.length > 0)
159
+ options.logger?.log(
160
+ `[summarization] condensed the history: ${state.messages.length} messages → a summary + ${messages.length - 2} kept`,
161
+ );
162
+ return update;
163
+ };
164
+ return {
165
+ ...inner,
166
+ beforeModel:
167
+ typeof beforeModel === 'function'
168
+ ? guarded
169
+ : { ...beforeModel, hook: guarded },
170
+ };
99
171
  };
172
+
173
+ function isBaseMessageLike(value: unknown): value is BaseMessage {
174
+ return (
175
+ typeof value === 'object' &&
176
+ value !== null &&
177
+ 'content' in value &&
178
+ 'additional_kwargs' in value
179
+ );
180
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Write-ahead marks for tool calls, and the resume policy built on them.
3
+ *
4
+ * Before any tool executes, its call id is recorded (`started`) in the run's
5
+ * marks; when it returns, the mark is closed (`done`). A run that is resumed
6
+ * after a reset finds the marks of the previous attempt and the graph about
7
+ * to execute the same tool calls again (LangGraph re-runs the pending node).
8
+ * The policy:
9
+ *
10
+ * - a call that never started runs normally;
11
+ * - a started, unfinished **write** call is NOT executed again — the model
12
+ * gets a synthetic result saying the outcome is unknown, so it can tell
13
+ * the user or verify with a read;
14
+ * - a started, unfinished **read** call runs again (it has no effect);
15
+ * - a call that already finished is not executed again either (its result
16
+ * is in the checkpoint's pending writes; reaching here means LangGraph
17
+ * could not replay it) — writes get the unknown-outcome result, reads run.
18
+ *
19
+ * The middleware also injects, into the first model call after a resume, a
20
+ * note carrying the tail of the reply the user had already seen, so the
21
+ * model continues the interrupted reply instead of starting over.
22
+ */
23
+ import {
24
+ type BaseMessage,
25
+ SystemMessage,
26
+ ToolMessage,
27
+ } from '@langchain/core/messages';
28
+ import { type AgentMiddleware, createMiddleware } from 'langchain';
29
+ import type { ToolEffect, ToolMark } from '../../do/run-store';
30
+ import type { Logger } from '../../plugin-api/types';
31
+ import { NOOP_LOGGER } from '../utils';
32
+
33
+ export type { ToolEffect };
34
+
35
+ /** The mark store slice the middleware drives (see `RunStore`). */
36
+ export interface ToolMarkStore {
37
+ startMark(input: {
38
+ runId: string;
39
+ toolCallId: string;
40
+ toolName: string;
41
+ effect: ToolEffect;
42
+ }): Promise<ToolMark | undefined>;
43
+ bumpMark(runId: string, toolCallId: string): Promise<void>;
44
+ finishMark(
45
+ runId: string,
46
+ toolCallId: string,
47
+ outcome: 'ok' | 'error' | 'interrupted',
48
+ ): Promise<void>;
49
+ }
50
+
51
+ export interface ToolMarksOptions {
52
+ runId: string;
53
+ store: ToolMarkStore;
54
+ /** The effect of a tool by name; unknown tools are writes (see `toolEffectOf`). */
55
+ effectOf: (toolName: string) => ToolEffect;
56
+ /**
57
+ * The reply text the user already received before the run was cut off.
58
+ * Set only on a resumed attempt; consumed by the first model call.
59
+ */
60
+ continuation?: string | null;
61
+ logger?: Logger;
62
+ }
63
+
64
+ /** The result handed to the model for a write call whose outcome cannot be known. */
65
+ export function unknownOutcomeToolResult(toolName: string): string {
66
+ return `The runtime was restarted while ${toolName} was executing, so whether it completed is unknown and it was NOT run again (running it twice could repeat its effect). Tell the user this step may or may not have happened, and verify with a read-only call before repeating it.`;
67
+ }
68
+
69
+ /** How many trailing characters of the interrupted reply the model is shown. */
70
+ export const CONTINUATION_TAIL_CHARS = 1200;
71
+
72
+ export function continuationNote(partialText: string): string {
73
+ const tail =
74
+ partialText.length > CONTINUATION_TAIL_CHARS
75
+ ? `…${partialText.slice(-CONTINUATION_TAIL_CHARS)}`
76
+ : partialText;
77
+ return (
78
+ 'Your reply to the latest user message was interrupted by a runtime restart. ' +
79
+ 'The user has already received this beginning of it:\n\n' +
80
+ `"""${tail}"""\n\n` +
81
+ 'Continue from exactly where it stopped, without repeating any of the text above, ' +
82
+ 'without apologising and without mentioning the interruption. If the reply above ' +
83
+ 'was already complete, respond with nothing more than a one-line closing remark.'
84
+ );
85
+ }
86
+
87
+ const READ_PREFIXES = [
88
+ 'list_',
89
+ 'get_',
90
+ 'search_',
91
+ 'read_',
92
+ 'preview_',
93
+ 'describe_',
94
+ 'check_',
95
+ 'explain_',
96
+ 'validate_',
97
+ 'view_',
98
+ ];
99
+ const READ_NAMES = new Set([
100
+ 'requirements',
101
+ 'flow_status',
102
+ 'compatible_actions',
103
+ 'oracle_list',
104
+ 'vfs_glob',
105
+ 'vfs_grep',
106
+ 'load_capability',
107
+ 'unload_capability',
108
+ ]);
109
+
110
+ /**
111
+ * Effect classification: an explicit declaration wins; MCP annotations count
112
+ * when the server sent them; otherwise a conservative name convention marks
113
+ * the obvious reads and everything else is a write (never re-run on resume).
114
+ */
115
+ export function toolEffectOf(tool: {
116
+ name: string;
117
+ effect?: ToolEffect;
118
+ annotations?: { readOnlyHint?: boolean; idempotentHint?: boolean };
119
+ }): ToolEffect {
120
+ if (tool.effect) return tool.effect;
121
+ if (tool.annotations?.readOnlyHint === true) return 'read';
122
+ if (READ_NAMES.has(tool.name)) return 'read';
123
+ // MCP tools arrive as `<server>__<tool>`; classify on the tool part.
124
+ const bare = tool.name.includes('__')
125
+ ? tool.name.slice(tool.name.lastIndexOf('__') + 2)
126
+ : tool.name;
127
+ if (READ_NAMES.has(bare)) return 'read';
128
+ for (const prefix of READ_PREFIXES)
129
+ if (bare.startsWith(prefix)) return 'read';
130
+ return 'write';
131
+ }
132
+
133
+ function isErrorResult(output: unknown): boolean {
134
+ if (ToolMessage.isInstance(output)) return output.status === 'error';
135
+ return false;
136
+ }
137
+
138
+ export function createToolMarksMiddleware(
139
+ options: ToolMarksOptions,
140
+ ): AgentMiddleware {
141
+ const logger = options.logger ?? NOOP_LOGGER;
142
+ let continuation = options.continuation ?? null;
143
+ return createMiddleware({
144
+ name: 'ToolMarksMiddleware',
145
+ wrapModelCall: async (request, handler) => {
146
+ if (!continuation || continuation.trim().length === 0)
147
+ return handler(request);
148
+ const note = continuationNote(continuation);
149
+ continuation = null;
150
+ const messages: BaseMessage[] = [
151
+ ...request.messages,
152
+ new SystemMessage(note),
153
+ ];
154
+ logger.log(
155
+ '[tool-marks] resumed run: continuation note added to the first model call',
156
+ );
157
+ return handler({ ...request, messages });
158
+ },
159
+ wrapToolCall: async (request, handler) => {
160
+ const { toolCall } = request;
161
+ const toolCallId = toolCall.id ?? '';
162
+ const toolName = toolCall.name;
163
+ if (!toolCallId) return handler(request);
164
+ const effect = options.effectOf(toolName);
165
+ const existing = await options.store.startMark({
166
+ runId: options.runId,
167
+ toolCallId,
168
+ toolName,
169
+ effect,
170
+ });
171
+ if (existing) {
172
+ if (effect === 'write') {
173
+ logger.warn(
174
+ `[tool-marks] ${toolName} (${toolCallId}) was ${existing.doneAt ? 'already executed' : 'interrupted mid-execution'} in an earlier attempt of run ${options.runId}; not run again`,
175
+ );
176
+ await options.store.finishMark(
177
+ options.runId,
178
+ toolCallId,
179
+ 'interrupted',
180
+ );
181
+ return new ToolMessage({
182
+ tool_call_id: toolCallId,
183
+ name: toolName,
184
+ content: unknownOutcomeToolResult(toolName),
185
+ status: 'error',
186
+ });
187
+ }
188
+ logger.log(
189
+ `[tool-marks] ${toolName} (${toolCallId}) is read-only; running it again after the interruption`,
190
+ );
191
+ await options.store.bumpMark(options.runId, toolCallId);
192
+ }
193
+ try {
194
+ const output = await handler(request);
195
+ await options.store.finishMark(
196
+ options.runId,
197
+ toolCallId,
198
+ isErrorResult(output) ? 'error' : 'ok',
199
+ );
200
+ return output;
201
+ } catch (error) {
202
+ await options.store
203
+ .finishMark(options.runId, toolCallId, 'error')
204
+ .catch(() => undefined);
205
+ throw error;
206
+ }
207
+ },
208
+ });
209
+ }
@@ -33,6 +33,14 @@ const openRouterModelsSchema = z.object({
33
33
  completion: z.string().optional(),
34
34
  })
35
35
  .optional(),
36
+ // The model's context window in tokens; `top_provider.context_length`
37
+ // is the window of the provider OpenRouter routes to by default and
38
+ // can be smaller than the model's nominal one — the smaller wins.
39
+ context_length: z.number().nullable().optional(),
40
+ top_provider: z
41
+ .object({ context_length: z.number().nullable().optional() })
42
+ .nullable()
43
+ .optional(),
36
44
  }),
37
45
  ),
38
46
  });
@@ -40,10 +48,26 @@ const openRouterModelsSchema = z.object({
40
48
  interface PriceCache {
41
49
  fetchedAt: number;
42
50
  prices: Map<string, ModelPrice>;
51
+ contextLengths: Map<string, number>;
43
52
  }
44
53
 
45
54
  let cache: PriceCache | null = null;
46
55
 
56
+ function parseContextLengths(
57
+ data: z.infer<typeof openRouterModelsSchema>['data'],
58
+ ): Map<string, number> {
59
+ const out = new Map<string, number>();
60
+ for (const model of data) {
61
+ const candidates = [
62
+ model.context_length,
63
+ model.top_provider?.context_length,
64
+ ].filter((n): n is number => typeof n === 'number' && n > 0);
65
+ if (candidates.length === 0) continue;
66
+ out.set(model.id, Math.floor(Math.min(...candidates)));
67
+ }
68
+ return out;
69
+ }
70
+
47
71
  /** Convert an OpenRouter `$/token` string to `$/million tokens`, or `null`. */
48
72
  function perMillion(pricePerToken: string | undefined): number | null {
49
73
  if (pricePerToken == null) return null;
@@ -106,7 +130,11 @@ export async function fetchOpenRouterPrices(
106
130
  if (prices.size === 0) {
107
131
  throw new Error('OpenRouter /models returned no usable pricing');
108
132
  }
109
- cache = { fetchedAt: now(), prices };
133
+ cache = {
134
+ fetchedAt: now(),
135
+ prices,
136
+ contextLengths: parseContextLengths(parsed.data.data),
137
+ };
110
138
  return prices;
111
139
  } catch (error) {
112
140
  logger.warn(
@@ -118,6 +146,19 @@ export async function fetchOpenRouterPrices(
118
146
  }
119
147
  }
120
148
 
149
+ /**
150
+ * Context windows (tokens) by OpenRouter model id, from the same cached
151
+ * `/models` fetch as the prices (one request serves both). Empty when the
152
+ * listing is unavailable — the resolver then falls back (see
153
+ * `context-window.ts`).
154
+ */
155
+ export async function fetchOpenRouterContextLengths(
156
+ opts: FetchOpenRouterPricesOptions = {},
157
+ ): Promise<ReadonlyMap<string, number>> {
158
+ await fetchOpenRouterPrices(opts);
159
+ return cache?.contextLengths ?? new Map<string, number>();
160
+ }
161
+
121
162
  /** Test seam: clear the in-memory price cache between cases. */
122
163
  export function resetOpenRouterPriceCache(): void {
123
164
  cache = null;
@@ -0,0 +1,77 @@
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
5
+ * following chunk or `null` at the end.
6
+ */
7
+ import { z } from 'zod';
8
+ import { tool } from '../plugin-api/tool-helper';
9
+ import type { PluginTool } from '../plugin-api/types';
10
+ import type { ReadResultOutcome } from '../do/result-store';
11
+
12
+ export interface ReadResultToolOptions {
13
+ read: (
14
+ id: string,
15
+ offset: number,
16
+ length: number,
17
+ ) => Promise<ReadResultOutcome>;
18
+ /** Longest chunk this deployment hands back (kept under the result cap). */
19
+ maxChars: number;
20
+ }
21
+
22
+ export const READ_RESULT_TOOL_NAME = 'read_result';
23
+
24
+ export function buildReadResultTool(
25
+ options: ReadResultToolOptions,
26
+ ): PluginTool {
27
+ const maxChars = Math.max(512, Math.min(options.maxChars, 64 * 1024));
28
+ const defaultChars = Math.min(4_000, maxChars);
29
+ const schema = z.object({
30
+ id: z
31
+ .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.'),
40
+ length: z
41
+ .number()
42
+ .int()
43
+ .min(256)
44
+ .max(maxChars)
45
+ .default(defaultChars)
46
+ .describe(`Bytes to read (max ${maxChars}).`),
47
+ });
48
+ return tool(
49
+ async (rawArgs) => {
50
+ const { id, offset, length } = schema.parse(rawArgs);
51
+ const outcome = await options.read(id, offset, length);
52
+ if (outcome.status !== 'ok')
53
+ return {
54
+ error:
55
+ outcome.status === 'expired'
56
+ ? 'This saved result has expired; run the tool again to get it back.'
57
+ : 'No saved result with this id in this session.',
58
+ };
59
+ return {
60
+ id: outcome.id,
61
+ offset: outcome.offset,
62
+ length: outcome.length,
63
+ size: outcome.size,
64
+ next: outcome.next,
65
+ text: outcome.text,
66
+ };
67
+ },
68
+ {
69
+ name: READ_RESULT_TOOL_NAME,
70
+ effect: 'read',
71
+ visibility: 'always',
72
+ 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.',
74
+ schema,
75
+ },
76
+ );
77
+ }
@@ -81,6 +81,9 @@ export interface MatrixAdapter {
81
81
  getEventById(roomId: string, eventId: string): Promise<MatrixEvent>;
82
82
  /** See `MatrixGatewayObject.botCredentials`. */
83
83
  botCredentials(): Promise<BotCredentials>;
84
+ /** Group rooms: room kind and channel memory (see `plugin-api/types.ts`). Hosts without a gateway omit them. */
85
+ roomInfo?: RuntimeContext['matrix']['roomInfo'];
86
+ channelMemory?: RuntimeContext['matrix']['channelMemory'];
84
87
  }
85
88
 
86
89
  /** LLM adapter — turns role tags into chat models. */
@@ -417,6 +420,10 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
417
420
  getRoomState: (roomId) => ambient.matrix.getRoomState(roomId),
418
421
  getEventById: (roomId, eventId) =>
419
422
  ambient.matrix.getEventById(roomId, eventId),
423
+ ...(ambient.matrix.roomInfo ? { roomInfo: ambient.matrix.roomInfo } : {}),
424
+ ...(ambient.matrix.channelMemory
425
+ ? { channelMemory: ambient.matrix.channelMemory }
426
+ : {}),
420
427
  },
421
428
  ucan: {
422
429
  hasCapability: (resource, action) =>
@@ -1,4 +1,4 @@
1
- import type { StructuredTool } from 'langchain';
1
+ import type { AgentMiddleware, StructuredTool } from 'langchain';
2
2
  import type {
3
3
  PluginContext,
4
4
  PluginSubAgent,
@@ -14,6 +14,7 @@ import type {
14
14
  } from './runtime-context';
15
15
  import { createSubagentAsTool, type AgentSpec } from './subagent-as-tool';
16
16
  import { wrapPluginTool } from './wrap-plugin-tool';
17
+ import type { ResultCapConfig } from './middlewares/result-cap';
17
18
 
18
19
  /** Inputs for collecting and wrapping sub-agents. */
19
20
  export interface CollectSubAgentsInput {
@@ -54,6 +55,10 @@ export interface CollectSubAgentsInput {
54
55
  * in name collisions.
55
56
  */
56
57
  passthroughTools?: StructuredTool[];
58
+ /** Middlewares every sub-agent's inner graph gets in addition to its own (the durable-run tool marks). */
59
+ extraMiddleware?: AgentMiddleware[];
60
+ /** The turn's result cap, applied to every wrapped sub-agent tool (see `wrapPluginTool`). */
61
+ resultCap?: ResultCapConfig;
57
62
  /**
58
63
  * Optional pre-collected sub-agent list. When provided, the registry is not
59
64
  * queried — callers that need to filter the entries collect from the
@@ -78,6 +83,8 @@ function defaultToAgentSpec(
78
83
  sessionId: string,
79
84
  sharedFactory: ((ctx: RuntimeContext) => SharedAccessors) | undefined,
80
85
  fallbackContext: RunConfigContext | undefined,
86
+ extraMiddleware?: AgentMiddleware[],
87
+ resultCap?: ResultCapConfig,
81
88
  ): AgentSpec {
82
89
  const systemPrompt =
83
90
  typeof subAgent.systemPrompt === 'function'
@@ -89,7 +96,13 @@ function defaultToAgentSpec(
89
96
  : subAgent.tools(buildCtx);
90
97
 
91
98
  const tools: StructuredTool[] = pluginTools.map((t) =>
92
- wrapPluginTool(t, { ambient, state, sharedFactory, fallbackContext }),
99
+ wrapPluginTool(t, {
100
+ ambient,
101
+ state,
102
+ sharedFactory,
103
+ fallbackContext,
104
+ ...(resultCap ? { resultCap } : {}),
105
+ }),
93
106
  );
94
107
 
95
108
  const model = ambient.llm.get(subAgent.model ?? 'subagent');
@@ -112,7 +125,7 @@ function defaultToAgentSpec(
112
125
  systemPrompt,
113
126
  tools,
114
127
  model,
115
- middleware: subAgent.middlewares,
128
+ middleware: [...(extraMiddleware ?? []), ...(subAgent.middlewares ?? [])],
116
129
  userDid,
117
130
  sessionId,
118
131
  logger: ambient.logger,
@@ -142,6 +155,8 @@ export async function collectSubAgentsWithFallback(
142
155
  toAgentSpec,
143
156
  passthroughTools,
144
157
  subAgents,
158
+ extraMiddleware,
159
+ resultCap,
145
160
  } = input;
146
161
 
147
162
  const entries = subAgents ?? (await registry.collect(buildCtx, rtCtx));
@@ -160,6 +175,8 @@ export async function collectSubAgentsWithFallback(
160
175
  sessionId,
161
176
  sharedFactory,
162
177
  fallbackContext,
178
+ extraMiddleware,
179
+ resultCap,
163
180
  );
164
181
  const withPassthrough: AgentSpec = passthroughTools?.length
165
182
  ? { ...spec, passthroughTools }
@@ -1,3 +1,5 @@
1
+ import { Command } from '@langchain/langgraph';
2
+ import { capToolResult, type ResultCapConfig } from './middlewares/result-cap';
1
3
  import { tool } from '@langchain/core/tools';
2
4
  import type { StructuredTool } from 'langchain';
3
5
  import type {
@@ -35,6 +37,13 @@ export interface WrapPluginToolOptions {
35
37
  * `invoke`/`stream` (the Node runtime does) overrides it.
36
38
  */
37
39
  fallbackContext?: RunConfigContext;
40
+ /**
41
+ * The turn's result cap (result-cap.ts). Applied to the handler's return
42
+ * value HERE, before LangChain turns it into a ToolMessage, so the SSE
43
+ * `tool_call` frame, the model and the transcript all carry the same
44
+ * capped text. Graph commands (`load_capability`) pass through.
45
+ */
46
+ resultCap?: ResultCapConfig;
38
47
  }
39
48
 
40
49
  /**
@@ -72,8 +81,14 @@ export function wrapPluginTool(
72
81
  pluginTool: PluginTool,
73
82
  options: WrapPluginToolOptions,
74
83
  ): StructuredTool {
75
- const { ambient, state, pluginTitle, sharedFactory, fallbackContext } =
76
- options;
84
+ const {
85
+ ambient,
86
+ state,
87
+ pluginTitle,
88
+ sharedFactory,
89
+ fallbackContext,
90
+ resultCap,
91
+ } = options;
77
92
  const description = pluginTitle
78
93
  ? `[${pluginTitle}] ${pluginTool.description}`
79
94
  : pluginTool.description;
@@ -89,7 +104,13 @@ export function wrapPluginTool(
89
104
  state,
90
105
  sharedFactory,
91
106
  );
92
- return pluginTool.handler(args, ctx);
107
+ const output = await pluginTool.handler(args, ctx);
108
+ if (!resultCap || output instanceof Command || output == null)
109
+ return output;
110
+ const text = typeof output === 'string' ? output : JSON.stringify(output);
111
+ if (typeof text !== 'string' || text.length <= resultCap.capChars)
112
+ return output;
113
+ return (await capToolResult(text, pluginTool.name, resultCap)).text;
93
114
  },
94
115
  {
95
116
  name: pluginTool.name,
package/src/do/ambient.ts CHANGED
@@ -141,6 +141,23 @@ export function createMatrixAdapter(
141
141
  postEvent: (roomId, eventType, content, opts) =>
142
142
  gateway.sendEvent(roomId, eventType, JSON.stringify(content), opts ?? {}),
143
143
  botCredentials: () => gateway.botCredentials(),
144
+ roomInfo: (roomId) => gateway.groupChatRoomInfo(roomId),
145
+ channelMemory: {
146
+ recall: async (roomId, limit) =>
147
+ JSON.parse(await gateway.channelMemoryRecall(roomId, limit)),
148
+ search: async (roomId, query, limit) =>
149
+ JSON.parse(await gateway.channelMemorySearch(roomId, query, limit)),
150
+ pin: async (args) =>
151
+ JSON.parse(
152
+ await gateway.channelMemoryPin(
153
+ args.roomId,
154
+ args.fact,
155
+ args.pinnedByDid,
156
+ args.sourceEventId,
157
+ ),
158
+ ),
159
+ unpin: (roomId, factId) => gateway.channelMemoryUnpin(roomId, factId),
160
+ },
144
161
  getRoomState: async (roomId): Promise<RoomStateSnapshot> => {
145
162
  const raw = await gateway.getRoomState(roomId);
146
163
  return { roomId, state: JSON.parse(raw) as unknown[] };