@ixo/oracle-runtime-workers 0.12.0 → 0.15.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 +14 -9
  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 +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -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 +198 -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 +1883 -264
  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 +523 -111
  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 +529 -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
@@ -6,22 +6,83 @@ import {
6
6
  import { type AgentMiddleware, createMiddleware } from 'langchain';
7
7
  import type { Logger } from '../../plugin-api/types';
8
8
  import { NOOP_LOGGER } from '../utils';
9
+ import { isCapabilityGateRefusal } from './capability-gate';
10
+ import { turnCarryOf, turnStart } from './turn-boundary';
11
+
12
+ export { turnStart };
13
+
14
+ /** Identical successful calls a turn may make, per effect. */
15
+ export interface RepetitionCaps {
16
+ reads: number;
17
+ writes: number;
18
+ }
19
+
20
+ export const DEFAULT_REPETITION_CAPS: RepetitionCaps = { reads: 5, writes: 1 };
21
+
22
+ /**
23
+ * Parse `TURN_MAX_IDENTICAL_READS` / `TURN_MAX_IDENTICAL_WRITES`; anything
24
+ * that is not a positive number keeps its default (as `turnLimitsFromEnv`).
25
+ */
26
+ export function repetitionCapsFromEnv(env: {
27
+ TURN_MAX_IDENTICAL_READS?: unknown;
28
+ TURN_MAX_IDENTICAL_WRITES?: unknown;
29
+ }): RepetitionCaps {
30
+ const positive = (raw: unknown, fallback: number): number => {
31
+ if (typeof raw !== 'number' && typeof raw !== 'string') return fallback;
32
+ if (raw === '') return fallback;
33
+ const n = Number(raw);
34
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
35
+ };
36
+ return {
37
+ reads: positive(
38
+ env.TURN_MAX_IDENTICAL_READS,
39
+ DEFAULT_REPETITION_CAPS.reads,
40
+ ),
41
+ writes: positive(
42
+ env.TURN_MAX_IDENTICAL_WRITES,
43
+ DEFAULT_REPETITION_CAPS.writes,
44
+ ),
45
+ };
46
+ }
9
47
 
10
48
  export interface ToolRepetitionGuardMiddlewareOptions {
11
49
  /**
12
- * How many messages back to scan for a prior identical failed call.
13
- * Default 20 — enough to catch in-turn loops without trawling whole histories.
50
+ * At most this many messages back to scan for a prior identical failed
51
+ * call. The scan never reaches past the start of the current turn;
52
+ * unset, it covers the whole turn.
14
53
  */
15
54
  lookback?: number;
55
+ /**
56
+ * Effect of a tool by name (the main agent passes its read/write map;
57
+ * a sub-agent dispatch is a write). Unset, every tool is capped as a read.
58
+ */
59
+ effectOf?: (toolName: string) => 'read' | 'write';
60
+ /** Identical successful calls of a read allowed per turn (default 5, `TURN_MAX_IDENTICAL_READS`). */
61
+ maxIdenticalReads?: number;
62
+ /** Identical successful calls of a write allowed per turn (default 1, `TURN_MAX_IDENTICAL_WRITES`). */
63
+ maxIdenticalWrites?: number;
16
64
  /** Optional logger; defaults to a no-op. */
17
65
  logger?: Logger;
18
66
  }
19
67
 
20
68
  /**
21
69
  * Detects when the agent issues the same `(toolName, args)` pair after that
22
- * exact call already failed inside the recent window, and short-circuits
23
- * before the tool is invoked again. The short-circuit message quotes the
24
- * earlier error and tells the model to change tools/args instead.
70
+ * exact call already failed in the current turn, and short-circuits before
71
+ * the tool is invoked again. The short-circuit message quotes the earlier
72
+ * error and tells the model to change tools/args instead.
73
+ *
74
+ * A turn starts at the latest human message (the user's, or a sub-agent's
75
+ * task) — not at a human message a tool injects mid-turn (see `turnStart`).
76
+ * A failure from an earlier turn never blocks: the user may have fixed its
77
+ * cause and asked again ("I granted access, try again").
78
+ *
79
+ * It also caps identical calls that SUCCEEDED in the turn: a write may run
80
+ * once per turn with the same arguments (again would repeat its effect —
81
+ * the user can ask again in a new message), a read up to
82
+ * `maxIdenticalReads` times (room for polling, not for a loop). An
83
+ * identical call earlier in the same model response counts too, since the
84
+ * calls of one response run side by side and neither sees the other's
85
+ * result.
25
86
  *
26
87
  * `toolRetryMiddleware` retries inside one call; this guard prevents the
27
88
  * model from making the *next* identical call.
@@ -30,7 +91,10 @@ export const createToolRepetitionGuardMiddleware = (
30
91
  options: ToolRepetitionGuardMiddlewareOptions = {},
31
92
  ): AgentMiddleware => {
32
93
  const logger = options.logger ?? NOOP_LOGGER;
33
- const lookback = options.lookback ?? 20;
94
+ const lookback = options.lookback ?? Number.POSITIVE_INFINITY;
95
+ const maxReads = options.maxIdenticalReads ?? DEFAULT_REPETITION_CAPS.reads;
96
+ const maxWrites =
97
+ options.maxIdenticalWrites ?? DEFAULT_REPETITION_CAPS.writes;
34
98
 
35
99
  return createMiddleware({
36
100
  name: 'ToolRepetitionGuardMiddleware',
@@ -41,19 +105,22 @@ export const createToolRepetitionGuardMiddleware = (
41
105
 
42
106
  const argsKey = canonicalArgsKey(toolCall.args);
43
107
  const messages = state.messages ?? [];
44
- const start = Math.max(0, messages.length - lookback);
45
-
46
- for (let i = messages.length - 1; i >= start; i--) {
47
- const msg = messages[i];
48
- if (!(msg instanceof ToolMessage)) continue;
49
- if (msg.status !== 'error') continue;
50
- if (msg.name !== toolName) continue;
51
-
52
- const priorArgs = findToolCallArgsById(messages, msg.tool_call_id);
53
- if (priorArgs === undefined) continue;
54
- if (canonicalArgsKey(priorArgs) !== argsKey) continue;
108
+ const opening = turnStart(messages);
109
+ const start = Math.max(opening, messages.length - lookback, 0);
110
+ const argsById = toolCallArgsById(messages, start);
111
+ // The turn's calls a mid-turn summary condensed away (oldest first):
112
+ // only while the lookback still reaches the summary.
113
+ const openingMessage = messages[opening];
114
+ const carried = (
115
+ start === opening && openingMessage
116
+ ? (turnCarryOf(openingMessage) ?? [])
117
+ : []
118
+ ).filter(
119
+ (call) =>
120
+ call.name === toolName && canonicalArgsKey(call.args) === argsKey,
121
+ );
55
122
 
56
- const priorError = toolMessageText(msg);
123
+ const failedInTurn = (priorError: string): ToolMessage => {
57
124
  logger.warn(
58
125
  `Repetition guard: short-circuiting duplicate failed call to ${toolName}`,
59
126
  { toolName, priorError },
@@ -72,13 +139,129 @@ export const createToolRepetitionGuardMiddleware = (
72
139
  name: toolName,
73
140
  status: 'error',
74
141
  });
142
+ };
143
+
144
+ for (let i = messages.length - 1; i >= start; i--) {
145
+ const msg = messages[i];
146
+ if (!(msg instanceof ToolMessage)) continue;
147
+ if (msg.status !== 'error') continue;
148
+ // The capability gate refused it without running it; after a
149
+ // `load_capability` the same call is the expected next step.
150
+ if (isCapabilityGateRefusal(msg)) continue;
151
+ if (msg.name !== toolName) continue;
152
+
153
+ const priorArgs = argsById.get(msg.tool_call_id);
154
+ if (priorArgs === undefined) continue;
155
+ if (canonicalArgsKey(priorArgs) !== argsKey) continue;
156
+
157
+ return failedInTurn(toolMessageText(msg));
158
+ }
159
+ const carriedFailure = carried.findLast(
160
+ (call) => call.status === 'error',
161
+ );
162
+ if (carriedFailure) return failedInTurn(carriedFailure.result);
163
+
164
+ // Identical calls that succeeded in this turn, plus identical calls
165
+ // earlier in this same model response (still running beside this one).
166
+ let identical = 0;
167
+ let lastResult: string | undefined;
168
+ for (const call of carried) {
169
+ if (call.status === 'error') continue;
170
+ identical += 1;
171
+ lastResult = call.result;
172
+ }
173
+ for (let i = start; i < messages.length; i++) {
174
+ const msg = messages[i];
175
+ if (!(msg instanceof ToolMessage)) continue;
176
+ if (msg.status === 'error' || msg.name !== toolName) continue;
177
+ const priorArgs = argsById.get(msg.tool_call_id);
178
+ if (priorArgs === undefined) continue;
179
+ if (canonicalArgsKey(priorArgs) !== argsKey) continue;
180
+ identical += 1;
181
+ lastResult = toolMessageText(msg);
182
+ }
183
+ for (const sibling of stepCallsBefore(messages, toolCall.id, start)) {
184
+ if (
185
+ sibling.name === toolName &&
186
+ canonicalArgsKey(sibling.args) === argsKey
187
+ )
188
+ identical += 1;
75
189
  }
190
+ const effect = options.effectOf?.(toolName) ?? 'read';
191
+ const cap = effect === 'write' ? maxWrites : maxReads;
192
+ if (identical < cap) return handler(toolCallRequest);
76
193
 
77
- return handler(toolCallRequest);
194
+ logger.warn(
195
+ `Repetition guard: ${toolName} already ran ${identical} time(s) this turn with these exact arguments (${effect} cap ${cap}); not run again`,
196
+ );
197
+ const earlier =
198
+ lastResult !== undefined
199
+ ? ['', 'It returned:', '', excerpt(lastResult)]
200
+ : [];
201
+ return new ToolMessage({
202
+ content: (effect === 'write'
203
+ ? [
204
+ lastResult !== undefined
205
+ ? `\`${toolName}\` already ran with these exact arguments in this turn, so it was NOT run again: running it twice would repeat its effect.`
206
+ : `An identical \`${toolName}\` call is already part of this step, so this one was NOT run: running it twice would repeat its effect.`,
207
+ ...earlier,
208
+ '',
209
+ 'Use that outcome. If the user wants it done again, confirm with them — they can ask again in a new message.',
210
+ ]
211
+ : [
212
+ `You have already called \`${toolName}\` with these exact arguments ${identical} times in this turn, so it was NOT called again.`,
213
+ ...earlier,
214
+ '',
215
+ 'Use the result you have, change the arguments, or pick a different tool.',
216
+ ]
217
+ ).join('\n'),
218
+ tool_call_id: toolCall.id ?? '',
219
+ name: toolName,
220
+ status: 'error',
221
+ });
78
222
  },
79
223
  });
80
224
  };
81
225
 
226
+ const EXCERPT_CHARS = 800;
227
+
228
+ function excerpt(text: string): string {
229
+ return text.length <= EXCERPT_CHARS
230
+ ? text
231
+ : `${text.slice(0, EXCERPT_CHARS)}… (${text.length - EXCERPT_CHARS} more characters)`;
232
+ }
233
+
234
+ /** Tool call id → arguments for every AI tool call from `start` on. */
235
+ function toolCallArgsById(
236
+ messages: readonly BaseMessage[],
237
+ start: number,
238
+ ): Map<string, unknown> {
239
+ const out = new Map<string, unknown>();
240
+ for (let i = start; i < messages.length; i++) {
241
+ const m = messages[i];
242
+ if (!m || !AIMessage.isInstance(m)) continue;
243
+ for (const tc of m.tool_calls ?? []) if (tc.id) out.set(tc.id, tc.args);
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /** The calls that precede `callId` in the model response that issued it. */
249
+ function stepCallsBefore(
250
+ messages: readonly BaseMessage[],
251
+ callId: string | undefined,
252
+ start: number,
253
+ ): Array<{ name: string; args: unknown }> {
254
+ if (!callId) return [];
255
+ for (let i = messages.length - 1; i >= start; i--) {
256
+ const m = messages[i];
257
+ if (!m || !AIMessage.isInstance(m)) continue;
258
+ const calls = m.tool_calls ?? [];
259
+ const at = calls.findIndex((tc) => tc.id === callId);
260
+ if (at >= 0) return calls.slice(0, at);
261
+ }
262
+ return [];
263
+ }
264
+
82
265
  function canonicalArgsKey(args: unknown): string {
83
266
  if (args === null || args === undefined) return 'null';
84
267
  if (typeof args !== 'object') return JSON.stringify(args);
@@ -107,19 +290,3 @@ function toolMessageText(msg: ToolMessage): string {
107
290
  }
108
291
  return '';
109
292
  }
110
-
111
- function findToolCallArgsById(
112
- messages: BaseMessage[],
113
- callId: string | undefined,
114
- ): unknown {
115
- if (!callId) return undefined;
116
- for (let i = messages.length - 1; i >= 0; i--) {
117
- const m = messages[i];
118
- if (!AIMessage.isInstance(m)) continue;
119
- const toolCalls = m.tool_calls;
120
- if (!toolCalls) continue;
121
- const hit = toolCalls.find((tc) => tc.id === callId);
122
- if (hit) return hit.args;
123
- }
124
- return undefined;
125
- }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Where the current turn starts in a thread's messages, and what a mid-turn
3
+ * summary keeps of the turn it condensed.
4
+ *
5
+ * A turn opens at the latest human message the user (or, in a sub-agent,
6
+ * the dispatching agent) wrote. The runtime writes human messages of its
7
+ * own — the summary that replaces a condensed history, the re-attachment
8
+ * `view_attachment` appends — and neither opens a turn.
9
+ *
10
+ * The summarizer can fire between two model steps of one turn and replace
11
+ * everything but the last few messages, the turn's opening message and its
12
+ * earlier tool calls included. The summary it writes then carries the
13
+ * calls of the turn it removed (`turn_carry`) and stands in as the turn's
14
+ * opening, so the repetition guard still knows what already ran this turn.
15
+ * A summary written at a turn boundary carries nothing and opens nothing.
16
+ */
17
+ import {
18
+ AIMessage,
19
+ HumanMessage,
20
+ ToolMessage,
21
+ type BaseMessage,
22
+ } from '@langchain/core/messages';
23
+ import { isAttachmentViewMessage } from '../../attachments/retention';
24
+ import { isCapabilityGateRefusal } from './capability-gate';
25
+
26
+ export const SUMMARY_PREFIX = 'Here is a summary of the conversation to date:';
27
+
28
+ /**
29
+ * `true` for the condensed-history message the summarization middleware
30
+ * writes into graph state. List endpoints use this to keep it out of the
31
+ * user-visible transcript.
32
+ */
33
+ export function isSummarizationMessage(message: BaseMessage): boolean {
34
+ if (message.additional_kwargs?.lc_source === 'summarization') return true;
35
+ const { content } = message;
36
+ return typeof content === 'string' && content.startsWith(SUMMARY_PREFIX);
37
+ }
38
+
39
+ /** A tool call of the current turn that a mid-turn summary condensed away. */
40
+ export interface CarriedToolCall {
41
+ name: string;
42
+ args: unknown;
43
+ /** `error` when its result was an error (the capability gate's refusals are not carried). */
44
+ status: 'success' | 'error';
45
+ /** The start of its result, for the repetition guard to quote. */
46
+ result: string;
47
+ }
48
+
49
+ /** `additional_kwargs` key of the calls a mid-turn summary carries. */
50
+ const TURN_CARRY = 'turn_carry';
51
+
52
+ /** Characters of a carried result kept (the guard quotes at most this much). */
53
+ export const CARRIED_RESULT_CHARS = 800;
54
+
55
+ /** The calls `message` carries when it is a summary written mid-turn, else `undefined`. */
56
+ export function turnCarryOf(
57
+ message: BaseMessage,
58
+ ): CarriedToolCall[] | undefined {
59
+ if (!isSummarizationMessage(message)) return undefined;
60
+ const raw: unknown = message.additional_kwargs?.[TURN_CARRY];
61
+ if (!Array.isArray(raw)) return undefined;
62
+ return raw.flatMap((entry: unknown): CarriedToolCall[] => {
63
+ if (!entry || typeof entry !== 'object') return [];
64
+ const name: unknown = Reflect.get(entry, 'name');
65
+ const status: unknown = Reflect.get(entry, 'status');
66
+ const result: unknown = Reflect.get(entry, 'result');
67
+ if (typeof name !== 'string') return [];
68
+ if (status !== 'success' && status !== 'error') return [];
69
+ return [
70
+ {
71
+ name,
72
+ args: Reflect.get(entry, 'args'),
73
+ status,
74
+ result: typeof result === 'string' ? result : '',
75
+ },
76
+ ];
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Index of the message that opens the current turn: the latest human
82
+ * message other than the ones the runtime writes itself, or a summary that
83
+ * carries the calls of a turn it condensed mid-way. 0 when there is none.
84
+ */
85
+ export function turnStart(messages: readonly BaseMessage[]): number {
86
+ for (let i = messages.length - 1; i >= 0; i--) {
87
+ const message = messages[i];
88
+ if (!message || !HumanMessage.isInstance(message)) continue;
89
+ if (isSummarizationMessage(message)) {
90
+ if (turnCarryOf(message) !== undefined) return i;
91
+ continue;
92
+ }
93
+ if (!isAttachmentViewMessage(message)) return i;
94
+ }
95
+ return 0;
96
+ }
97
+
98
+ function resultText(message: ToolMessage): string {
99
+ const { content } = message;
100
+ const text =
101
+ typeof content === 'string'
102
+ ? content
103
+ : content
104
+ .map((block) =>
105
+ typeof block === 'string'
106
+ ? block
107
+ : 'text' in block && typeof block.text === 'string'
108
+ ? block.text
109
+ : '',
110
+ )
111
+ .join('\n');
112
+ return text.length <= CARRIED_RESULT_CHARS
113
+ ? text
114
+ : `${text.slice(0, CARRIED_RESULT_CHARS)}…`;
115
+ }
116
+
117
+ /**
118
+ * The calls of the current turn that a summary of `before` would remove: the
119
+ * turn's opening is among the removed messages (it is not in `keptIds`), so
120
+ * the turn continues after the summary. `undefined` when the summary falls
121
+ * on a turn boundary (the whole current turn is kept). Calls an earlier
122
+ * mid-turn summary of this same turn carried are carried on.
123
+ */
124
+ export function carriedCallsOfTurn(
125
+ before: readonly BaseMessage[],
126
+ keptIds: ReadonlySet<string>,
127
+ ): CarriedToolCall[] | undefined {
128
+ const start = turnStart(before);
129
+ const opening = before[start];
130
+ if (!opening || !HumanMessage.isInstance(opening)) return undefined;
131
+ if (opening.id !== undefined && keptIds.has(opening.id)) return undefined;
132
+ const carried: CarriedToolCall[] = [...(turnCarryOf(opening) ?? [])];
133
+ const calls = new Map<string, { name: string; args: unknown }>();
134
+ for (let i = start; i < before.length; i += 1) {
135
+ const message = before[i];
136
+ if (!message || (message.id !== undefined && keptIds.has(message.id)))
137
+ continue;
138
+ if (AIMessage.isInstance(message)) {
139
+ for (const call of message.tool_calls ?? [])
140
+ if (call.id) calls.set(call.id, { name: call.name, args: call.args });
141
+ continue;
142
+ }
143
+ if (!ToolMessage.isInstance(message) || isCapabilityGateRefusal(message))
144
+ continue;
145
+ const call = calls.get(message.tool_call_id);
146
+ if (!call) continue;
147
+ carried.push({
148
+ name: message.name ?? call.name,
149
+ args: call.args,
150
+ status: message.status === 'error' ? 'error' : 'success',
151
+ result: resultText(message),
152
+ });
153
+ }
154
+ return carried;
155
+ }
156
+
157
+ /** `additional_kwargs` that make a summary carry `calls` (see `turnCarryOf`). */
158
+ export function turnCarryKwargs(
159
+ calls: readonly CarriedToolCall[],
160
+ ): Record<string, unknown> {
161
+ return { [TURN_CARRY]: calls.map((call) => ({ ...call })) };
162
+ }
@@ -11,12 +11,28 @@
11
11
  *
12
12
  * Per isolate rather than per process: a cold isolate pays one ~100 ms fetch
13
13
  * on its first `/models`, then serves from memory until evicted.
14
+ *
15
+ * Every turn reads it (the context-window resolver), so it never holds a
16
+ * turn up for long: the fetch is cut off after `OPENROUTER_FETCH_TIMEOUT_MS`,
17
+ * concurrent callers share one fetch, an expired listing is served while a
18
+ * fresh one is fetched in the background, and a failure is remembered for
19
+ * `OPENROUTER_FAILURE_TTL_MS` before the next attempt.
14
20
  */
15
21
  import { z } from 'zod';
16
22
  import type { ModelPrice } from './llm';
17
23
 
18
24
  export const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
19
25
  export const OPENROUTER_PRICE_CACHE_TTL_MS = 60 * 60 * 1000;
26
+ /** How long a `/models` fetch (response and body) may take before it counts as failed. */
27
+ export const OPENROUTER_FETCH_TIMEOUT_MS = 3_000;
28
+ /** How long a failed fetch is remembered before the next one is attempted. */
29
+ export const OPENROUTER_FAILURE_TTL_MS = 60_000;
30
+ /**
31
+ * Past its timeout plus this, a shared fetch that never settled (the runtime
32
+ * dropped the request that started it, its timer with it) is abandoned and
33
+ * the next caller starts a new one.
34
+ */
35
+ export const OPENROUTER_INFLIGHT_MARGIN_MS = 1_000;
20
36
 
21
37
  /**
22
38
  * OpenRouter returns `pricing.prompt` / `pricing.completion` as strings in USD
@@ -52,6 +68,20 @@ interface PriceCache {
52
68
  }
53
69
 
54
70
  let cache: PriceCache | null = null;
71
+ interface InflightFetch {
72
+ done: Promise<void>;
73
+ /** When it started, by the caller's clock. */
74
+ startedAt: number;
75
+ /** How long it may stay shared (its timeout plus the margin). */
76
+ limitMs: number;
77
+ }
78
+
79
+ /** The fetch in flight, shared by every caller that needs it. */
80
+ let inflight: InflightFetch | null = null;
81
+ /** When the last fetch failed (by the caller's clock); `null` after a success. */
82
+ let failedAt: number | null = null;
83
+ /** Bumped by a reset: a refresh started before it settles without writing. */
84
+ let generation = 0;
55
85
 
56
86
  function parseContextLengths(
57
87
  data: z.infer<typeof openRouterModelsSchema>['data'],
@@ -96,33 +126,62 @@ export interface FetchOpenRouterPricesOptions {
96
126
  fetch?: (input: string, init?: RequestInit) => Promise<Response>;
97
127
  now?: () => number;
98
128
  logger?: { warn: (message: string) => void };
129
+ /** Fetch timeout (default `OPENROUTER_FETCH_TIMEOUT_MS`). */
130
+ timeoutMs?: number;
131
+ /**
132
+ * Keeps a background refresh alive after the caller has its answer
133
+ * (`ctx.waitUntil` on Workers). Without it the refresh is merely detached.
134
+ */
135
+ waitUntil?: (work: Promise<unknown>) => void;
99
136
  }
100
137
 
101
- /**
102
- * Live OpenRouter prices keyed by model id ($/million tokens). Cached for an
103
- * hour. Never throws: on any failure it returns the last cached map, or an
104
- * empty map (so the caller falls back to the catalog's baseline prices).
105
- */
106
- export async function fetchOpenRouterPrices(
107
- opts: FetchOpenRouterPricesOptions = {},
108
- ): Promise<ReadonlyMap<string, ModelPrice>> {
138
+ /** `work`, or a rejection once `ms` have passed (the timer never outlives it). */
139
+ function withTimeout<T>(
140
+ work: Promise<T>,
141
+ ms: number,
142
+ controller: AbortController,
143
+ ): Promise<T> {
144
+ let timer: ReturnType<typeof setTimeout> | undefined;
145
+ const timeout = new Promise<never>((_, reject) => {
146
+ timer = setTimeout(() => {
147
+ const error = new Error(`OpenRouter /models timed out after ${ms} ms`);
148
+ error.name = 'TimeoutError';
149
+ controller.abort(error);
150
+ reject(error);
151
+ }, ms);
152
+ });
153
+ return Promise.race([work, timeout]).finally(() => clearTimeout(timer));
154
+ }
155
+
156
+ /** One fetch of the listing into `cache`; a failure is logged and remembered, never thrown. */
157
+ async function refresh(opts: FetchOpenRouterPricesOptions): Promise<void> {
109
158
  const now = opts.now ?? Date.now;
110
- if (cache && now() - cache.fetchedAt < OPENROUTER_PRICE_CACHE_TTL_MS) {
111
- return cache.prices;
112
- }
113
159
  // The global `fetch` must be called as a function of the global scope on
114
160
  // workerd ("Illegal invocation" when handed around as a bare reference).
115
161
  const fetchImpl =
116
162
  opts.fetch ?? ((input: string, init?: RequestInit) => fetch(input, init));
117
163
  const logger = opts.logger ?? console;
164
+ const controller = new AbortController();
165
+ const startedIn = generation;
118
166
  try {
119
- const response = await fetchImpl(OPENROUTER_MODELS_URL, {
120
- headers: opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {},
121
- });
122
- if (!response.ok) {
123
- throw new Error(`OpenRouter /models returned HTTP ${response.status}`);
124
- }
125
- const parsed = openRouterModelsSchema.safeParse(await response.json());
167
+ const parsed = await withTimeout(
168
+ (async () => {
169
+ const response = await fetchImpl(OPENROUTER_MODELS_URL, {
170
+ headers: opts.apiKey
171
+ ? { Authorization: `Bearer ${opts.apiKey}` }
172
+ : {},
173
+ signal: controller.signal,
174
+ });
175
+ if (!response.ok) {
176
+ throw new Error(
177
+ `OpenRouter /models returned HTTP ${response.status}`,
178
+ );
179
+ }
180
+ return openRouterModelsSchema.safeParse(await response.json());
181
+ })(),
182
+ opts.timeoutMs ?? OPENROUTER_FETCH_TIMEOUT_MS,
183
+ controller,
184
+ );
126
185
  if (!parsed.success) {
127
186
  throw new Error(`Unexpected /models payload: ${parsed.error.message}`);
128
187
  }
@@ -130,19 +189,87 @@ export async function fetchOpenRouterPrices(
130
189
  if (prices.size === 0) {
131
190
  throw new Error('OpenRouter /models returned no usable pricing');
132
191
  }
192
+ if (generation !== startedIn) return;
133
193
  cache = {
134
194
  fetchedAt: now(),
135
195
  prices,
136
196
  contextLengths: parseContextLengths(parsed.data.data),
137
197
  };
138
- return prices;
198
+ failedAt = null;
139
199
  } catch (error) {
200
+ if (generation !== startedIn) return;
201
+ failedAt = now();
140
202
  logger.warn(
141
- `[models] live OpenRouter pricing unavailable — serving baseline prices: ${
203
+ `[models] live OpenRouter pricing unavailable — serving ${cache ? 'the last listing' : 'baseline prices'}: ${
142
204
  error instanceof Error ? error.message : String(error)
143
205
  }`,
144
206
  );
145
- return cache?.prices ?? new Map<string, ModelPrice>();
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Live OpenRouter prices keyed by model id ($/million tokens). Cached for an
212
+ * hour. Never throws: on any failure it returns the last cached map, or an
213
+ * empty map (so the caller falls back to the catalog's baseline prices).
214
+ * Waits at most the fetch timeout, and only when nothing is cached.
215
+ */
216
+ export async function fetchOpenRouterPrices(
217
+ opts: FetchOpenRouterPricesOptions = {},
218
+ ): Promise<ReadonlyMap<string, ModelPrice>> {
219
+ const now = opts.now ?? Date.now;
220
+ if (cache && now() - cache.fetchedAt < OPENROUTER_PRICE_CACHE_TTL_MS) {
221
+ return cache.prices;
222
+ }
223
+ // A clock that went backwards does not keep a failure remembered.
224
+ const sinceFailure = failedAt === null ? null : now() - failedAt;
225
+ const failedRecently =
226
+ sinceFailure !== null &&
227
+ sinceFailure >= 0 &&
228
+ sinceFailure < OPENROUTER_FAILURE_TTL_MS;
229
+ if (!failedRecently) {
230
+ const limitMs =
231
+ (opts.timeoutMs ?? OPENROUTER_FETCH_TIMEOUT_MS) +
232
+ OPENROUTER_INFLIGHT_MARGIN_MS;
233
+ if (inflight) {
234
+ const age = now() - inflight.startedAt;
235
+ if (age < 0 || age > inflight.limitMs) inflight = null;
236
+ }
237
+ const shared = inflight ?? startRefresh(opts, now(), limitMs);
238
+ if (!cache) await settledWithin(shared.done, limitMs);
239
+ else opts.waitUntil?.(shared.done);
240
+ }
241
+ return cache?.prices ?? new Map<string, ModelPrice>();
242
+ }
243
+
244
+ function startRefresh(
245
+ opts: FetchOpenRouterPricesOptions,
246
+ startedAt: number,
247
+ limitMs: number,
248
+ ): InflightFetch {
249
+ const entry: InflightFetch = {
250
+ done: refresh(opts).finally(() => {
251
+ // An abandoned fetch that settles late leaves its successor alone.
252
+ if (inflight === entry) inflight = null;
253
+ }),
254
+ startedAt,
255
+ limitMs,
256
+ };
257
+ inflight = entry;
258
+ return entry;
259
+ }
260
+
261
+ /** Waits for `work`, or for `ms` on this caller's own timer, whichever is first. */
262
+ async function settledWithin(work: Promise<void>, ms: number): Promise<void> {
263
+ let timer: ReturnType<typeof setTimeout> | undefined;
264
+ try {
265
+ await Promise.race([
266
+ work,
267
+ new Promise<void>((resolve) => {
268
+ timer = setTimeout(resolve, ms);
269
+ }),
270
+ ]);
271
+ } finally {
272
+ clearTimeout(timer);
146
273
  }
147
274
  }
148
275
 
@@ -159,7 +286,13 @@ export async function fetchOpenRouterContextLengths(
159
286
  return cache?.contextLengths ?? new Map<string, number>();
160
287
  }
161
288
 
162
- /** Test seam: clear the in-memory price cache between cases. */
289
+ /**
290
+ * Test seam: clear the in-memory price cache, the shared fetch and the
291
+ * remembered failure. A refresh still running settles without writing.
292
+ */
163
293
  export function resetOpenRouterPriceCache(): void {
164
294
  cache = null;
295
+ inflight = null;
296
+ failedAt = null;
297
+ generation += 1;
165
298
  }