@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +310 -0
  18. package/src/channels/contract.ts +246 -0
  19. package/src/channels/turns.ts +280 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +2003 -274
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +562 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -22,8 +22,36 @@ export interface PageContextMiddlewareOptions {
22
22
  logger?: Logger;
23
23
  }
24
24
 
25
+ /** Longest page title rendered (characters); longer ones are cut. */
26
+ export const PAGE_TITLE_MAX_CHARS = 120;
27
+ /** Longest room id rendered; a real Matrix room id is far shorter. */
28
+ const ROOM_ID_MAX_CHARS = 255;
29
+
30
+ /**
31
+ * Text set by other people (a page title any room member can change, a room
32
+ * id from the request) as inert single-line text: line breaks, control and
33
+ * format characters become spaces, runs of whitespace one space, the
34
+ * markdown and markup characters that open code, emphasis, headings,
35
+ * quotes, tables, links or tags (`` ` * # > < [ ] | ``) are dropped, the
36
+ * length is capped. What remains cannot open a section, a list or a tag in
37
+ * the system prompt.
38
+ */
39
+ export function inertInline(text: string, maxChars: number): string {
40
+ const flat = text
41
+ .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ')
42
+ .replace(/[`*#><|[\]]/g, '')
43
+ .replace(/\s+/g, ' ')
44
+ .trim();
45
+ return flat.length <= maxChars
46
+ ? flat
47
+ : `${flat.slice(0, maxChars).trimEnd()}…`;
48
+ }
49
+
25
50
  function formatLabel(title: string | undefined, roomId: string): string {
26
- return title ? `"${title}" (${roomId})` : roomId;
51
+ const id = inertInline(roomId, ROOM_ID_MAX_CHARS);
52
+ const name = title ? inertInline(title, PAGE_TITLE_MAX_CHARS) : '';
53
+ // Quoted as a value (JSON string rules): a quote in the title cannot end it.
54
+ return name ? `${JSON.stringify(name)} (${id})` : id;
27
55
  }
28
56
 
29
57
  /**
@@ -31,12 +59,28 @@ function formatLabel(title: string | undefined, roomId: string): string {
31
59
  * the agent is editing a page (`state.editorRoomId` is set). When the user
32
60
  * switches pages mid-conversation the block flags it explicitly so the
33
61
  * agent re-reads the current page before editing.
62
+ *
63
+ * A page's title is looked up once per room for the life of the middleware
64
+ * (one turn: the main agent is built per turn), not on every model step.
34
65
  */
35
66
  export const createPageContextMiddleware = (
36
67
  options: PageContextMiddlewareOptions,
37
68
  ): AgentMiddleware => {
38
69
  const logger = options.logger ?? NOOP_LOGGER;
39
- const { getRoomTitle } = options;
70
+ const titles = new Map<string, Promise<string | undefined>>();
71
+ const getRoomTitle = (roomId: string): Promise<string | undefined> => {
72
+ let title = titles.get(roomId);
73
+ if (!title) {
74
+ title = options.getRoomTitle(roomId).catch((error: unknown) => {
75
+ logger.warn(
76
+ `[PageContextMiddleware] title lookup failed for ${roomId}: ${error instanceof Error ? error.message : String(error)}`,
77
+ );
78
+ return undefined;
79
+ });
80
+ titles.set(roomId, title);
81
+ }
82
+ return title;
83
+ };
40
84
 
41
85
  return createMiddleware({
42
86
  name: 'PageContextMiddleware',
@@ -0,0 +1,47 @@
1
+ /**
2
+ * LangChain's agent loop exits when the LAST tool result of a step comes
3
+ * from a return-direct tool. A model that calls one alongside other tools
4
+ * would then never see those other results. This middleware moves the
5
+ * return-direct call to the front of such a step, so the loop continues and
6
+ * the model answers with every result in view.
7
+ */
8
+ import { AIMessage, isAIMessage } from '@langchain/core/messages';
9
+ import { type AgentMiddleware, createMiddleware } from 'langchain';
10
+
11
+ export function createReturnDirectFirstMiddleware(
12
+ toolNames: ReadonlySet<string>,
13
+ ): AgentMiddleware {
14
+ return createMiddleware({
15
+ name: 'ReturnDirectFirstMiddleware',
16
+ afterModel: (state) => {
17
+ const last = state.messages.at(-1);
18
+ if (!last || !isAIMessage(last)) return undefined;
19
+ const calls = last.tool_calls ?? [];
20
+ const lastCall = calls.at(-1);
21
+ // Only a return-direct call in last place ends the loop early, and
22
+ // only when some other tool's result would go unanswered.
23
+ if (!lastCall || !toolNames.has(lastCall.name)) return undefined;
24
+ if (calls.every((c) => toolNames.has(c.name))) return undefined;
25
+ const direct = calls.filter((c) => toolNames.has(c.name));
26
+ return {
27
+ messages: [
28
+ new AIMessage({
29
+ id: last.id,
30
+ name: last.name,
31
+ content: last.content,
32
+ additional_kwargs: last.additional_kwargs,
33
+ response_metadata: last.response_metadata,
34
+ ...(last.usage_metadata
35
+ ? { usage_metadata: last.usage_metadata }
36
+ : {}),
37
+ invalid_tool_calls: last.invalid_tool_calls ?? [],
38
+ tool_calls: [
39
+ ...direct,
40
+ ...calls.filter((c) => !toolNames.has(c.name)),
41
+ ],
42
+ }),
43
+ ],
44
+ };
45
+ },
46
+ });
47
+ }
@@ -1,7 +1,20 @@
1
1
  import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
2
- import type { BaseMessage } from '@langchain/core/messages';
2
+ import {
3
+ AIMessage,
4
+ HumanMessage,
5
+ ToolMessage,
6
+ type BaseMessage,
7
+ } from '@langchain/core/messages';
3
8
  import { type AgentMiddleware, summarizationMiddleware } from 'langchain';
4
9
  import type { Logger } from '../../plugin-api/types';
10
+ import type { TurnBudget } from '../turn-budget';
11
+ import {
12
+ carriedCallsOfTurn,
13
+ isSummarizationMessage,
14
+ SUMMARY_PREFIX,
15
+ turnCarryKwargs,
16
+ turnStart,
17
+ } from './turn-boundary';
5
18
 
6
19
  /**
7
20
  * IXO-flavoured prompt instructing the summarizer to preserve identifiers
@@ -48,7 +61,7 @@ Messages to summarize:
48
61
  {messages}
49
62
  </messages>`;
50
63
 
51
- export const SUMMARY_PREFIX = 'Here is a summary of the conversation to date:';
64
+ export { isSummarizationMessage, SUMMARY_PREFIX };
52
65
 
53
66
  const DEFAULT_TRIGGER_MESSAGES = 20;
54
67
  const DEFAULT_TRIGGER_TOKENS = 40_000;
@@ -73,6 +86,12 @@ export interface SummarizationMiddlewareOptions {
73
86
  keepMessages?: number;
74
87
  /** What the summarizer itself may read (tokens); default LangChain's. */
75
88
  summaryInputTokens?: number;
89
+ /**
90
+ * The turn's budget: a summary is not started after the deadline or the
91
+ * abort, and its model call is charged like any other (the metered
92
+ * adapter does that; the check here is the cheap early exit).
93
+ */
94
+ budget?: TurnBudget;
76
95
  logger?: Pick<Logger, 'warn' | 'log'>;
77
96
  }
78
97
 
@@ -94,21 +113,101 @@ export function isFailedSummary(message: BaseMessage): boolean {
94
113
  return FAILED_SUMMARY.test(body);
95
114
  }
96
115
 
116
+ interface ApproximateChars {
117
+ content: unknown;
118
+ toolCalls: unknown;
119
+ toolCallId: unknown;
120
+ chars: number;
121
+ }
122
+
123
+ const approximateChars = new WeakMap<BaseMessage, ApproximateChars>();
124
+
97
125
  /**
98
- * `true` for the condensed-history message the summarization middleware
99
- * writes into graph state. List endpoints use this to keep it out of the
100
- * user-visible transcript.
126
+ * The characters LangChain's `countTokensApproximately` counts for one
127
+ * message (its text, an AI message's tool calls as JSON, a tool message's
128
+ * call id), memoised per message while those values are unchanged.
101
129
  */
102
- export function isSummarizationMessage(message: BaseMessage): boolean {
103
- if (message.additional_kwargs?.lc_source === 'summarization') return true;
130
+ function charsOf(message: BaseMessage): number {
131
+ const toolCalls = AIMessage.isInstance(message)
132
+ ? message.tool_calls
133
+ : undefined;
134
+ const toolCallId = ToolMessage.isInstance(message)
135
+ ? message.tool_call_id
136
+ : undefined;
137
+ const cached = approximateChars.get(message);
138
+ if (
139
+ cached &&
140
+ cached.content === message.content &&
141
+ cached.toolCalls === toolCalls &&
142
+ cached.toolCallId === toolCallId
143
+ )
144
+ return cached.chars;
104
145
  const { content } = message;
105
- return typeof content === 'string' && content.startsWith(SUMMARY_PREFIX);
146
+ let text: string;
147
+ if (typeof content === 'string') text = content;
148
+ else if (Array.isArray(content))
149
+ text = content
150
+ .map((item: unknown) => {
151
+ if (typeof item === 'string') return item;
152
+ if (
153
+ item &&
154
+ typeof item === 'object' &&
155
+ 'type' in item &&
156
+ item.type === 'text' &&
157
+ 'text' in item
158
+ )
159
+ return item.text;
160
+ return '';
161
+ })
162
+ .join('');
163
+ else text = '';
164
+ if (Array.isArray(toolCalls) && toolCalls.length > 0)
165
+ text += JSON.stringify(toolCalls);
166
+ if (ToolMessage.isInstance(message)) text += toolCallId ?? '';
167
+ approximateChars.set(message, {
168
+ content,
169
+ toolCalls,
170
+ toolCallId,
171
+ chars: text.length,
172
+ });
173
+ return text.length;
174
+ }
175
+
176
+ /**
177
+ * LangChain's `countTokensApproximately` over messages (chars/4 of the
178
+ * total, rounded up) with the per-message work memoised: the summarizer
179
+ * counts the whole history before every model step, and again while it
180
+ * trims what it reads.
181
+ */
182
+ export function countMessageTokensApproximately(
183
+ messages: readonly BaseMessage[],
184
+ ): number {
185
+ let chars = 0;
186
+ for (const message of messages) chars += charsOf(message);
187
+ return Math.ceil(chars / 4);
188
+ }
189
+
190
+ /** The turn-opening message of `messages`, by id and by reference. */
191
+ function openingOf(messages: readonly BaseMessage[]): {
192
+ id?: string;
193
+ message?: BaseMessage;
194
+ } {
195
+ const message = messages[turnStart(messages)];
196
+ return { id: message?.id, message };
106
197
  }
107
198
 
108
199
  /**
109
200
  * Wraps LangChain's built-in `summarizationMiddleware` with the IXO-specific
110
201
  * summary prompt + prefix. Pass any compatible chat model — typically the
111
202
  * cheap "routing" role.
203
+ *
204
+ * A summary that fails is not attempted again before the next turn: the
205
+ * history it could not condense is the same one on every later step, and
206
+ * each attempt would send all of it again.
207
+ *
208
+ * A summary written in the middle of a turn — the turn's opening message is
209
+ * among what it condenses — carries the turn's earlier tool calls
210
+ * (turn-boundary.ts), so the repetition guard still sees them.
112
211
  */
113
212
  export const createSummarizationMiddleware = (
114
213
  options: SummarizationMiddlewareOptions,
@@ -129,6 +228,7 @@ export const createSummarizationMiddleware = (
129
228
  { tokens: options.triggerTokens ?? DEFAULT_TRIGGER_TOKENS },
130
229
  ],
131
230
  keep: { messages: options.keepMessages ?? DEFAULT_KEEP_MESSAGES },
231
+ tokenCounter: countMessageTokensApproximately,
132
232
  ...(options.summaryInputTokens !== undefined
133
233
  ? { trimTokensToSummarize: options.summaryInputTokens }
134
234
  : {}),
@@ -138,9 +238,21 @@ export const createSummarizationMiddleware = (
138
238
  // The hook is either a bare handler or `{ hook, canJumpTo }`; keep the shape.
139
239
  const handler =
140
240
  typeof beforeModel === 'function' ? beforeModel : beforeModel.hook;
241
+ // The opening message of the turn whose summary failed.
242
+ let failedTurn: { id?: string; message?: BaseMessage } | undefined;
243
+ const sameTurn = (messages: readonly BaseMessage[]): boolean => {
244
+ if (!failedTurn) return false;
245
+ const now = openingOf(messages);
246
+ return (
247
+ (now.message !== undefined && now.message === failedTurn.message) ||
248
+ (now.id !== undefined && now.id === failedTurn.id)
249
+ );
250
+ };
141
251
  // A summary that failed keeps the history exactly as it was: the turn
142
252
  // runs on the full context and the next turn tries again.
143
253
  const guarded: typeof handler = async (state, runtime) => {
254
+ options.budget?.check(runtime.signal);
255
+ if (sameTurn(state.messages)) return undefined;
144
256
  const update = await handler(state, runtime);
145
257
  const messages =
146
258
  update && typeof update === 'object' && 'messages' in update
@@ -150,15 +262,18 @@ export const createSummarizationMiddleware = (
150
262
  ? messages.find((m) => isBaseMessageLike(m) && isFailedSummary(m))
151
263
  : undefined;
152
264
  if (failed) {
265
+ failedTurn = openingOf(state.messages);
153
266
  options.logger?.warn(
154
- `[summarization] summary failed; keeping the full history this turn: ${String(failed.content).slice(0, 300)}`,
267
+ `[summarization] summary failed; keeping the full history for the rest of this turn: ${String(failed.content).slice(0, 300)}`,
155
268
  );
156
269
  return undefined;
157
270
  }
158
- if (Array.isArray(messages) && messages.length > 0)
271
+ if (Array.isArray(messages) && messages.length > 0) {
272
+ carryTurnCalls(state.messages, messages);
159
273
  options.logger?.log(
160
274
  `[summarization] condensed the history: ${state.messages.length} messages → a summary + ${messages.length - 2} kept`,
161
275
  );
276
+ }
162
277
  return update;
163
278
  };
164
279
  return {
@@ -170,6 +285,33 @@ export const createSummarizationMiddleware = (
170
285
  };
171
286
  };
172
287
 
288
+ /**
289
+ * When the summary in `update` removes the current turn's opening message,
290
+ * record on it the turn's tool calls it condensed (turn-boundary.ts).
291
+ */
292
+ function carryTurnCalls(
293
+ before: readonly BaseMessage[],
294
+ update: readonly unknown[],
295
+ ): void {
296
+ const summary = update.find(
297
+ (m): m is BaseMessage =>
298
+ isBaseMessageLike(m) &&
299
+ HumanMessage.isInstance(m) &&
300
+ isSummarizationMessage(m),
301
+ );
302
+ if (!summary) return;
303
+ const keptIds = new Set<string>();
304
+ for (const m of update)
305
+ if (isBaseMessageLike(m) && m !== summary && typeof m.id === 'string')
306
+ keptIds.add(m.id);
307
+ const carried = carriedCallsOfTurn(before, keptIds);
308
+ if (carried === undefined) return;
309
+ summary.additional_kwargs = {
310
+ ...summary.additional_kwargs,
311
+ ...turnCarryKwargs(carried),
312
+ };
313
+ }
314
+
173
315
  function isBaseMessageLike(value: unknown): value is BaseMessage {
174
316
  return (
175
317
  typeof value === 'object' &&
@@ -0,0 +1,323 @@
1
+ /**
2
+ * How a tool call is executed once the resume policy (tool-marks.ts) let it
3
+ * run: charged to the turn's budget, scheduled in its lane, and — for a
4
+ * write — claimed in the run ledger for as long as its outcome is unknown.
5
+ *
6
+ * The claim is a fingerprint of the tool name and its canonical arguments,
7
+ * recorded before the write starts. A returned result (success or a
8
+ * reported failure) releases it; a failure that says nothing about whether
9
+ * the write happened — abort, deadline, a dropped connection, a 5xx —
10
+ * leaves it in place, whether the tool threw it or caught it and returned
11
+ * it as an error result (many plugin tools and adapters never throw to the
12
+ * agent: `{ ok: false, error }`, an error-status ToolMessage, `Error: …`).
13
+ * An identical write attempted while a claim stands is
14
+ * not executed: the model is told the earlier outcome is unknown and asked
15
+ * to verify with a read (a `warned` claim). A later turn that asks for the
16
+ * same write again, after that warning, runs it — the user was informed and
17
+ * asked again — and the ledger drops claims with the run retention.
18
+ *
19
+ * Placed innermost around the tool (`MainAgentHooks.toolExecution`): it
20
+ * must see the tool's own thrown error, which the retry middlewares would
21
+ * otherwise have turned into an error ToolMessage, and each retry attempt
22
+ * is charged and scheduled again. Applied to every sub-agent's inner graph
23
+ * too, so a sub-agent's own writes are claimed. Sub-agent dispatches are
24
+ * counted as tool attempts and scheduled in their own lane, never
25
+ * fingerprinted.
26
+ */
27
+ import { reportsUnknownOutcome } from '@ixo/common/ai/frontend-bridge';
28
+ import { ToolMessage } from '@langchain/core/messages';
29
+ import { type AgentMiddleware, createMiddleware } from 'langchain';
30
+ import type { Logger } from '../../plugin-api/types';
31
+ import type { ToolLane, ToolScheduler } from '../tool-scheduler';
32
+ import { isHarnessLimitError, type TurnBudget } from '../turn-budget';
33
+ import { NOOP_LOGGER } from '../utils';
34
+
35
+ export type WriteClaim =
36
+ | { status: 'claimed' }
37
+ | { status: 'blocked'; toolName: string; since: string };
38
+
39
+ /** The claim slice of the run ledger (see `RunStore`). */
40
+ export interface WriteClaimStore {
41
+ claimWrite(input: {
42
+ fingerprint: string;
43
+ toolName: string;
44
+ runId: string;
45
+ sessionId: string;
46
+ }): Promise<WriteClaim>;
47
+ releaseWrite(fingerprint: string, runId: string): Promise<void>;
48
+ }
49
+
50
+ export interface ToolExecutionOptions {
51
+ budget: TurnBudget;
52
+ scheduler: ToolScheduler;
53
+ /** Lane of a tool by name (`read`, `write`, or `subagent` for a dispatch). */
54
+ laneOf: (toolName: string) => ToolLane;
55
+ runId: string;
56
+ sessionId: string;
57
+ /** Omitted → writes are scheduled and charged but not claimed (stateless builds, tests). */
58
+ claims?: WriteClaimStore;
59
+ /** The turn's abort signal: waiting for a slot ends with it. */
60
+ signal?: AbortSignal;
61
+ logger?: Logger;
62
+ }
63
+
64
+ /** Stable text for a value: object keys sorted, arrays in order. */
65
+ export function canonicalArguments(value: unknown): string {
66
+ if (Array.isArray(value))
67
+ return `[${value.map(canonicalArguments).join(',')}]`;
68
+ if (value && typeof value === 'object')
69
+ return `{${Object.entries(value)
70
+ .filter(([, item]) => item !== undefined)
71
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
72
+ .map(
73
+ ([key, item]) => `${JSON.stringify(key)}:${canonicalArguments(item)}`,
74
+ )
75
+ .join(',')}}`;
76
+ return JSON.stringify(value) ?? 'null';
77
+ }
78
+
79
+ /** SHA-256 of `name:args`, hex — the write's fingerprint in the ledger. */
80
+ export async function operationKey(
81
+ name: string,
82
+ args: unknown,
83
+ ): Promise<string> {
84
+ const bytes = new TextEncoder().encode(`${name}:${canonicalArguments(args)}`);
85
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
86
+ return Array.from(new Uint8Array(digest), (b) =>
87
+ b.toString(16).padStart(2, '0'),
88
+ ).join('');
89
+ }
90
+
91
+ /** The result handed to the model for a write it must not repeat yet. */
92
+ export function uncertainWriteToolResult(toolName: string): string {
93
+ return `An identical ${toolName} call was started earlier and its outcome is unknown (it was interrupted or its connection dropped), so it was NOT run again: running it twice could repeat its effect. Verify with a read-only call whether it already happened and tell the user; only repeat it if they confirm.`;
94
+ }
95
+
96
+ /**
97
+ * Transport error codes (Node / undici `code`s; workerd only has them in the
98
+ * message) and the editor's undelivered write (`write_not_saved`: the
99
+ * homeserver kept failing, and a send that failed with a server error may
100
+ * still have landed). Any of them can follow a request that reached the
101
+ * service, so none proves the effect did not happen.
102
+ */
103
+ const UNCERTAIN_CODES =
104
+ 'ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|UND_ERR_(?:SOCKET|CLOSED|CONNECT_TIMEOUT|HEADERS_TIMEOUT|BODY_TIMEOUT)|write_not_saved';
105
+ const UNCERTAIN_CODE = new RegExp(`^(?:${UNCERTAIN_CODES})$`, 'i');
106
+
107
+ /**
108
+ * Failure text that says nothing about whether the side effect happened,
109
+ * anchored to how such a failure is reported — never a bare word a definite
110
+ * refusal can contain ("network 'base' not supported", "invalid timeout",
111
+ * "payment aborted by the user"):
112
+ * - an error code above, as a whole word;
113
+ * - a transport failure's own message: undici's `fetch failed`, `socket
114
+ * hang up`, workerd's `Network connection lost`, `network error`;
115
+ * - a deadline: `timed out`, `timeout exceeded` / `timeout of <n>`, the
116
+ * `TimeoutError` / `AbortError` names, DOMException's "operation was
117
+ * aborted";
118
+ * - a 5xx: its status (`HTTP 503`, `status 502`, `status code: 500`) or
119
+ * its reason phrase.
120
+ */
121
+ const UNCERTAIN_TEXT = new RegExp(
122
+ [
123
+ `\\b(?:${UNCERTAIN_CODES})\\b`,
124
+ '\\bfetch failed\\b',
125
+ '\\bsocket hang up\\b',
126
+ '\\bnetwork (?:error|connection (?:was )?lost)\\b',
127
+ '\\btimed out\\b',
128
+ '\\btimeout (?:exceeded|of \\d)',
129
+ '\\b(?:TimeoutError|AbortError)\\b',
130
+ '\\boperation was aborted\\b',
131
+ '\\b(?:HTTP|status(?: code)?):? ?5\\d\\d\\b',
132
+ '\\b(?:internal server error|bad gateway|service unavailable|gateway time-?out)\\b',
133
+ ].join('|'),
134
+ 'i',
135
+ );
136
+
137
+ const isServerStatus = (status: unknown): boolean =>
138
+ typeof status === 'number' && status >= 500 && status <= 599;
139
+
140
+ /**
141
+ * `true` when a thrown error says nothing about whether the tool's side
142
+ * effect happened: the turn was aborted or hit its deadline, the transport
143
+ * failed, or the service answered with a server error.
144
+ */
145
+ export function isUncertainOutcome(
146
+ error: unknown,
147
+ signal?: AbortSignal,
148
+ ): boolean {
149
+ if (signal?.aborted) return true;
150
+ if (isHarnessLimitError(error)) return true;
151
+ if (!(error instanceof Error)) return false;
152
+ if (error.name === 'AbortError' || error.name === 'TimeoutError') return true;
153
+ if (
154
+ 'code' in error &&
155
+ typeof error.code === 'string' &&
156
+ UNCERTAIN_CODE.test(error.code)
157
+ )
158
+ return true;
159
+ const status =
160
+ 'status' in error && typeof error.status === 'number'
161
+ ? error.status
162
+ : undefined;
163
+ if (status !== undefined) return isServerStatus(status);
164
+ return UNCERTAIN_TEXT.test(error.message);
165
+ }
166
+
167
+ function isRecord(value: unknown): value is Record<string, unknown> {
168
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
169
+ }
170
+
171
+ function toolMessageText(message: ToolMessage): string {
172
+ const { content } = message;
173
+ if (typeof content === 'string') return content;
174
+ return content
175
+ .map((block) =>
176
+ typeof block === 'string'
177
+ ? block
178
+ : 'text' in block && typeof block.text === 'string'
179
+ ? block.text
180
+ : '',
181
+ )
182
+ .join('\n');
183
+ }
184
+
185
+ /**
186
+ * The failure a JSON tool result reports, when it is one: `ok`, `success` or
187
+ * `successful` false, `isError` true, or an `error` field. The text joins
188
+ * whatever describes it (error, message, reason, code, detail) and a server
189
+ * status (`status` / `statusCode`) is carried apart.
190
+ */
191
+ function reportedFailure(
192
+ body: Record<string, unknown>,
193
+ ): { text: string; serverStatus: boolean } | null {
194
+ const failed =
195
+ body.ok === false ||
196
+ body.success === false ||
197
+ body.successful === false ||
198
+ body.isError === true ||
199
+ (body.error !== undefined && body.error !== null && body.error !== false);
200
+ if (!failed) return null;
201
+ const parts: string[] = [];
202
+ let serverStatus =
203
+ isServerStatus(body.status) || isServerStatus(body.statusCode);
204
+ const collect = (value: unknown): void => {
205
+ if (typeof value === 'string') parts.push(value);
206
+ else if (isRecord(value)) {
207
+ if (isServerStatus(value.status) || isServerStatus(value.statusCode))
208
+ serverStatus = true;
209
+ for (const key of ['message', 'code', 'name', 'reason', 'detail'])
210
+ collect(value[key]);
211
+ }
212
+ };
213
+ for (const key of ['error', 'message', 'reason', 'code', 'name', 'detail'])
214
+ collect(body[key]);
215
+ return { text: parts.join(' '), serverStatus };
216
+ }
217
+
218
+ /**
219
+ * Why a RETURNED tool result leaves a write's outcome unknown, or null when
220
+ * it does not. A JSON result whose `outcome` is `'unknown'` says so itself.
221
+ * Otherwise only an error-shaped result counts — an error-status
222
+ * ToolMessage, a JSON failure (`reportedFailure`), or text that opens with
223
+ * `Error` / `Failed` — and only when what it reports is the uncertain kind
224
+ * `isUncertainOutcome` recognises in a thrown error (a timeout, a transport
225
+ * failure, a 5xx). A failure the service did report (validation, 404, 403)
226
+ * is a known outcome, and a success mentioning a timeout is not a failure.
227
+ */
228
+ export function uncertainResultReason(output: unknown): string | null {
229
+ if (!ToolMessage.isInstance(output)) return null;
230
+ const text = toolMessageText(output).trim();
231
+ let failure: { text: string; serverStatus: boolean } | null = null;
232
+ try {
233
+ const parsed: unknown = JSON.parse(text);
234
+ // A result that says so itself (the frontend bridge's
235
+ // FRONTEND_OUTCOME_UNKNOWN, or a client reporting the same).
236
+ if (reportsUnknownOutcome(parsed)) return 'outcome unknown';
237
+ if (isRecord(parsed)) failure = reportedFailure(parsed);
238
+ } catch {
239
+ // Not JSON: plain text, judged below.
240
+ }
241
+ if (
242
+ !failure &&
243
+ (output.status === 'error' || /^(error|failed)\b/i.test(text))
244
+ )
245
+ failure = { text, serverStatus: false };
246
+ if (!failure) return null;
247
+ if (failure.serverStatus) return 'server error';
248
+ const match = UNCERTAIN_TEXT.exec(failure.text);
249
+ return match ? match[0] : null;
250
+ }
251
+
252
+ export function createToolExecutionMiddleware(
253
+ options: ToolExecutionOptions,
254
+ ): AgentMiddleware {
255
+ const logger = options.logger ?? NOOP_LOGGER;
256
+ const { budget, scheduler, claims } = options;
257
+ // Executions per tool call id: a second one is a retry (the log is the
258
+ // operator's only view of a read that failed and then succeeded).
259
+ const attempts = new Map<string, number>();
260
+ return createMiddleware({
261
+ name: 'ToolExecutionMiddleware',
262
+ wrapToolCall: async (request, handler) => {
263
+ const { toolCall } = request;
264
+ const toolName = toolCall.name;
265
+ const lane = options.laneOf(toolName);
266
+ const callId = toolCall.id ?? '';
267
+ const attempt = (attempts.get(callId) ?? 0) + 1;
268
+ attempts.set(callId, attempt);
269
+ if (attempt > 1)
270
+ logger.log(
271
+ `[tool-execution] ${toolName} (${callId}): attempt ${attempt}`,
272
+ );
273
+ // Refused before waiting for a slot: a turn over its limit takes no
274
+ // more slots. The slot wait itself ends with the abort signal.
275
+ budget.check(options.signal);
276
+ return scheduler.run(lane, options.signal, async () => {
277
+ budget.reserveTool(options.signal);
278
+ if (lane !== 'write' || !claims) return handler(request);
279
+ const fingerprint = await operationKey(toolName, toolCall.args);
280
+ const claim = await claims.claimWrite({
281
+ fingerprint,
282
+ toolName,
283
+ runId: options.runId,
284
+ sessionId: options.sessionId,
285
+ });
286
+ if (claim.status === 'blocked') {
287
+ logger.warn(
288
+ `[tool-execution] ${toolName}: an identical write since ${claim.since} has no known outcome; not run again`,
289
+ );
290
+ return new ToolMessage({
291
+ tool_call_id: toolCall.id ?? '',
292
+ name: toolName,
293
+ content: uncertainWriteToolResult(toolName),
294
+ status: 'error',
295
+ });
296
+ }
297
+ try {
298
+ const output = await handler(request);
299
+ const uncertain = uncertainResultReason(output);
300
+ if (uncertain !== null) {
301
+ logger.warn(
302
+ `[tool-execution] ${toolName}: returned a failure that leaves its outcome unknown (${uncertain}); its claim stays in the ledger`,
303
+ );
304
+ } else {
305
+ await claims.releaseWrite(fingerprint, options.runId);
306
+ }
307
+ return output;
308
+ } catch (error) {
309
+ if (isUncertainOutcome(error, options.signal)) {
310
+ logger.warn(
311
+ `[tool-execution] ${toolName}: outcome unknown (${error instanceof Error ? error.message.split('\n')[0] : String(error)}); its claim stays in the ledger`,
312
+ );
313
+ } else {
314
+ await claims
315
+ .releaseWrite(fingerprint, options.runId)
316
+ .catch(() => undefined);
317
+ }
318
+ throw error;
319
+ }
320
+ });
321
+ },
322
+ });
323
+ }