@ixo/oracle-runtime-workers 0.13.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 +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 +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 +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 +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 +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 +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
@@ -0,0 +1,189 @@
1
+ import { toolPlaneRequirement } from '../plugin-api/tool-plane';
2
+ import type {
3
+ Logger,
4
+ PluginContext,
5
+ PluginManifest,
6
+ PluginTool,
7
+ } from '../plugin-api/types';
8
+ import type { CapabilityRequirement } from './manifest';
9
+ import type {
10
+ RegisteredSubAgent,
11
+ SubAgentRegistry,
12
+ ToolRegistry,
13
+ } from './registries';
14
+ import { computeSubAgentToolName } from './subagent-as-tool';
15
+ import { NOOP_LOGGER } from './utils';
16
+
17
+ /** `ctx.ucan.hasCapability` bound to the turn's delegation. */
18
+ export type HasCapability = (resource: string, action: string) => boolean;
19
+
20
+ /**
21
+ * Tool-level counterpart of the manifest's `unmetRequirements`: the
22
+ * capability `tool`'s plane requires that the delegation does not grant
23
+ * (empty for an orchestration tool, or an admin tool it reaches).
24
+ */
25
+ export function unmetToolRequirements(
26
+ pluginName: string,
27
+ tool: Pick<PluginTool, 'name' | 'plane'>,
28
+ has: HasCapability,
29
+ ): CapabilityRequirement[] {
30
+ const required = toolPlaneRequirement(pluginName, tool);
31
+ return required && !has(required.resource, required.action) ? [required] : [];
32
+ }
33
+
34
+ /** What the turn's delegation leaves of the collected tools and sub-agents. */
35
+ export interface TurnToolAccess<
36
+ T extends { pluginName: string; tool: PluginTool },
37
+ > {
38
+ /** The collected plugin tools the delegation reaches, in collection order. */
39
+ tools: T[];
40
+ /**
41
+ * The sub-agents still exposed, each with its `tools` resolved and cut to
42
+ * the ones the delegation reaches and its `forwardTools` cut to match. A
43
+ * sub-agent that had tools and is left with none is not exposed; one
44
+ * declared without tools (routing only) is.
45
+ */
46
+ subAgents: RegisteredSubAgent[];
47
+ /**
48
+ * Main-agent tool names the turn withholds: inaccessible admin tools and
49
+ * the `call_*` tool of a sub-agent that is not exposed. Never bound; the
50
+ * capability gate refuses a call naming one.
51
+ */
52
+ withheldToolNames: ReadonlySet<string>;
53
+ /**
54
+ * Plugins that contributed tools or sub-agents of which none is left.
55
+ * Treated like a plugin whose `requires` are unmet (left out of the
56
+ * prompt, refused by `load_capability`, never preloaded) except that they
57
+ * are hidden outright: `list_capabilities` does not list them.
58
+ */
59
+ hiddenPlugins: ReadonlySet<string>;
60
+ }
61
+
62
+ /**
63
+ * Apply the tool planes to one turn's collection. The single rule every
64
+ * surface reads: binding, sub-agents, the Tier-1 prompt, the meta-tools,
65
+ * the capability gate and the router's candidates.
66
+ *
67
+ * A sub-agent whose `tools` factory throws is dropped and logged, as its
68
+ * conversion failure always was (`collectSubAgentsWithFallback`).
69
+ */
70
+ export function resolveTurnToolAccess<
71
+ T extends { pluginName: string; tool: PluginTool },
72
+ >(input: {
73
+ tools: readonly T[];
74
+ subAgents: readonly RegisteredSubAgent[];
75
+ buildCtx: PluginContext;
76
+ has: HasCapability;
77
+ logger: Logger;
78
+ }): TurnToolAccess<T> {
79
+ const { buildCtx, has, logger } = input;
80
+ const reaches = (pluginName: string, tool: PluginTool): boolean =>
81
+ unmetToolRequirements(pluginName, tool, has).length === 0;
82
+
83
+ const contributing = new Set<string>();
84
+ const usable = new Set<string>();
85
+ const withheldToolNames = new Set<string>();
86
+
87
+ const tools: T[] = [];
88
+ for (const entry of input.tools) {
89
+ contributing.add(entry.pluginName);
90
+ if (reaches(entry.pluginName, entry.tool)) {
91
+ tools.push(entry);
92
+ usable.add(entry.pluginName);
93
+ } else {
94
+ withheldToolNames.add(entry.tool.name);
95
+ }
96
+ }
97
+
98
+ const subAgents: RegisteredSubAgent[] = [];
99
+ for (const { pluginName, subAgent } of input.subAgents) {
100
+ let declared: PluginTool[];
101
+ try {
102
+ declared = Array.isArray(subAgent.tools)
103
+ ? subAgent.tools
104
+ : subAgent.tools(buildCtx);
105
+ } catch (err) {
106
+ logger.error(
107
+ `[main-agent] sub-agent init failed for plugin "${pluginName}"; skipping: ${
108
+ err instanceof Error ? err.message : String(err)
109
+ }`,
110
+ );
111
+ continue;
112
+ }
113
+ contributing.add(pluginName);
114
+ const reached = declared.filter((tool) => reaches(pluginName, tool));
115
+ if (declared.length > 0 && reached.length === 0) {
116
+ withheldToolNames.add(computeSubAgentToolName(subAgent.name));
117
+ continue;
118
+ }
119
+ const reachedNames = new Set(reached.map((tool) => tool.name));
120
+ const withheldInner = new Set(
121
+ declared
122
+ .filter((tool) => !reachedNames.has(tool.name))
123
+ .map((tool) => tool.name),
124
+ );
125
+ subAgents.push({
126
+ pluginName,
127
+ subAgent: {
128
+ ...subAgent,
129
+ tools: reached,
130
+ ...(Array.isArray(subAgent.forwardTools)
131
+ ? {
132
+ forwardTools: subAgent.forwardTools.filter(
133
+ (name) => !withheldInner.has(name),
134
+ ),
135
+ }
136
+ : {}),
137
+ },
138
+ });
139
+ usable.add(pluginName);
140
+ }
141
+
142
+ const hiddenPlugins = new Set(
143
+ [...contributing].filter((pluginName) => !usable.has(pluginName)),
144
+ );
145
+ return { tools, subAgents, withheldToolNames, hiddenPlugins };
146
+ }
147
+
148
+ /**
149
+ * `hiddenPlugins` from the boot-time contributions alone, for the
150
+ * capability router, which predicts before the turn's request-time tools are
151
+ * collected. A plugin whose request-time tools would make it usable may be
152
+ * left out here (the router does not offer it; `load_capability` still
153
+ * can); one whose request-time tools are all withheld may be offered, and
154
+ * the turn's build voids that preload (`createMainAgent`).
155
+ */
156
+ export async function bootHiddenPlugins(input: {
157
+ registries: { tools: ToolRegistry; subAgents: SubAgentRegistry };
158
+ buildCtx: PluginContext;
159
+ has: HasCapability;
160
+ }): Promise<ReadonlySet<string>> {
161
+ const { registries, buildCtx, has } = input;
162
+ return resolveTurnToolAccess({
163
+ tools: await registries.tools.collectBoot(buildCtx),
164
+ subAgents: registries.subAgents.collectBoot(buildCtx),
165
+ buildCtx,
166
+ has,
167
+ // A failing sub-agent is reported by the turn's build, once.
168
+ logger: NOOP_LOGGER,
169
+ }).hiddenPlugins;
170
+ }
171
+
172
+ /**
173
+ * The manifest as this turn may show it: `examples` naming a withheld tool
174
+ * removed, so neither the Tier-1 prompt nor `load_capability` teaches the
175
+ * model a tool it cannot see. Same reference when nothing is removed.
176
+ */
177
+ export function withoutWithheldExamples(
178
+ manifest: PluginManifest,
179
+ withheldToolNames: ReadonlySet<string>,
180
+ ): PluginManifest {
181
+ const examples = manifest.examples;
182
+ if (!examples || withheldToolNames.size === 0) return manifest;
183
+ const kept = examples.filter(
184
+ (example) => !withheldToolNames.has(example.tool),
185
+ );
186
+ return kept.length === examples.length
187
+ ? manifest
188
+ : { ...manifest, examples: kept };
189
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Bounded tool concurrency for one user object, shared by every session and
3
+ * every turn that object runs:
4
+ *
5
+ * - writes run one at a time (a mutation never races another mutation of
6
+ * the same user's data, even from two simultaneous sessions);
7
+ * - reads run up to `maxReads` at a time;
8
+ * - sub-agent dispatches run up to `maxSubagents` at a time, in their own
9
+ * lane so a child waiting for one of the parent's slots can never
10
+ * deadlock the parent.
11
+ *
12
+ * A wait is cancelled by the turn's abort signal: the waiter leaves the
13
+ * queue and the signal's reason is thrown, so an aborted turn never holds a
14
+ * slot it will not use.
15
+ */
16
+ export type ToolLane = 'read' | 'write' | 'subagent';
17
+
18
+ class Lane {
19
+ private active = 0;
20
+ private readonly waiting: Array<{
21
+ resolve: () => void;
22
+ reject: (reason: unknown) => void;
23
+ signal?: AbortSignal;
24
+ onAbort?: () => void;
25
+ }> = [];
26
+
27
+ constructor(private readonly capacity: number) {}
28
+
29
+ acquire(signal?: AbortSignal): Promise<void> {
30
+ if (signal?.aborted) return Promise.reject(abortReason(signal));
31
+ if (this.active < this.capacity) {
32
+ this.active += 1;
33
+ return Promise.resolve();
34
+ }
35
+ return new Promise<void>((resolve, reject) => {
36
+ const waiter: (typeof this.waiting)[number] = { resolve, reject, signal };
37
+ if (signal) {
38
+ waiter.onAbort = () => {
39
+ const index = this.waiting.indexOf(waiter);
40
+ if (index !== -1) this.waiting.splice(index, 1);
41
+ reject(abortReason(signal));
42
+ };
43
+ signal.addEventListener('abort', waiter.onAbort, { once: true });
44
+ }
45
+ this.waiting.push(waiter);
46
+ });
47
+ }
48
+
49
+ release(): void {
50
+ const next = this.waiting.shift();
51
+ if (!next) {
52
+ this.active -= 1;
53
+ return;
54
+ }
55
+ // The slot passes straight to the next waiter (`active` is unchanged).
56
+ if (next.signal && next.onAbort)
57
+ next.signal.removeEventListener('abort', next.onAbort);
58
+ next.resolve();
59
+ }
60
+
61
+ get inFlight(): number {
62
+ return this.active;
63
+ }
64
+
65
+ get queued(): number {
66
+ return this.waiting.length;
67
+ }
68
+ }
69
+
70
+ /** The signal's reason as an Error (an aborter may pass any value). */
71
+ function abortReason(signal: AbortSignal): Error {
72
+ const reason: unknown = signal.reason;
73
+ if (reason instanceof Error) return reason;
74
+ const error = new Error(
75
+ reason === undefined
76
+ ? 'The turn was aborted while waiting for a tool slot.'
77
+ : String(reason),
78
+ );
79
+ error.name = 'AbortError';
80
+ return error;
81
+ }
82
+
83
+ export interface ToolSchedulerOptions {
84
+ maxReads?: number;
85
+ maxSubagents?: number;
86
+ }
87
+
88
+ export class ToolScheduler {
89
+ private readonly lanes: Record<ToolLane, Lane>;
90
+
91
+ constructor(options: ToolSchedulerOptions = {}) {
92
+ this.lanes = {
93
+ write: new Lane(1),
94
+ read: new Lane(options.maxReads ?? 4),
95
+ subagent: new Lane(options.maxSubagents ?? 4),
96
+ };
97
+ }
98
+
99
+ async run<T>(
100
+ lane: ToolLane,
101
+ signal: AbortSignal | undefined,
102
+ action: () => Promise<T>,
103
+ ): Promise<T> {
104
+ const slot = this.lanes[lane];
105
+ await slot.acquire(signal);
106
+ try {
107
+ return await action();
108
+ } finally {
109
+ slot.release();
110
+ }
111
+ }
112
+
113
+ /** Occupancy per lane (diagnostics). */
114
+ snapshot(): Record<ToolLane, { inFlight: number; queued: number }> {
115
+ const entry = (lane: Lane) => ({
116
+ inFlight: lane.inFlight,
117
+ queued: lane.queued,
118
+ });
119
+ return {
120
+ read: entry(this.lanes.read),
121
+ write: entry(this.lanes.write),
122
+ subagent: entry(this.lanes.subagent),
123
+ };
124
+ }
125
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * One turn's resource budget, shared by the main agent, every sub-agent it
3
+ * dispatches and the helper models (summarizer, attachment extraction) the
4
+ * turn's LLM adapter hands out. Three limits:
5
+ *
6
+ * - `tokens` — cumulative model tokens. A call reserves an estimate
7
+ * (chars/4 of what it sends plus the output reserve) before
8
+ * it starts and settles to the provider's reported usage
9
+ * when the call ends, so the counter is an estimate only
10
+ * while a call is in flight.
11
+ * - `tools` — tool attempts, counting a sub-agent dispatch and each
12
+ * retry of a read.
13
+ * - `durationMs` — wall clock since the budget was created (the turn's
14
+ * deadline; the host aborts the run when it passes).
15
+ *
16
+ * Exhaustion is terminal for the turn: `HarnessLimitError` is not retryable
17
+ * and the work already done (checkpoints, tool results) is kept.
18
+ *
19
+ * Token counts are estimates reconciled against provider usage, never a
20
+ * billing figure; `TURN_RECURSION_LIMIT` stays the separate guard against a
21
+ * runaway graph.
22
+ */
23
+ export class HarnessLimitError extends Error {
24
+ readonly retryable = false;
25
+
26
+ constructor(
27
+ readonly kind: 'budget_exhausted',
28
+ readonly limit: 'tokens' | 'tools' | 'time',
29
+ message: string,
30
+ ) {
31
+ super(message);
32
+ this.name = 'HarnessLimitError';
33
+ }
34
+ }
35
+
36
+ const LIMITS: ReadonlySet<string> = new Set(['tokens', 'tools', 'time']);
37
+
38
+ function limitOf(error: Error): HarnessLimitError['limit'] | undefined {
39
+ const value: unknown = Reflect.get(error, 'limit');
40
+ return value === 'tokens' || value === 'tools' || value === 'time'
41
+ ? value
42
+ : undefined;
43
+ }
44
+
45
+ /**
46
+ * The `HarnessLimitError` behind `error`, if there is one. LangChain wraps an
47
+ * error thrown from a middleware in a `MiddlewareError` — same name and
48
+ * message, the original as `cause`, once per middleware layer — so the
49
+ * original (with `kind` and `limit`) is found by walking the causes. A copy
50
+ * of the class from another bundle is recognized by name and fields.
51
+ */
52
+ export function harnessLimitOf(error: unknown): HarnessLimitError | undefined {
53
+ let current: unknown = error;
54
+ for (let depth = 0; depth < 32 && current instanceof Error; depth += 1) {
55
+ if (current instanceof HarnessLimitError) return current;
56
+ const limit = limitOf(current);
57
+ if (current.name === 'HarnessLimitError' && limit && LIMITS.has(limit))
58
+ return new HarnessLimitError('budget_exhausted', limit, current.message);
59
+ current = current.cause;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /** `true` when `error` is, or wraps, a turn-budget exhaustion. */
65
+ export function isHarnessLimitError(error: unknown): boolean {
66
+ return harnessLimitOf(error) !== undefined;
67
+ }
68
+
69
+ export interface TurnLimits {
70
+ /** Cumulative model tokens (estimated input + reserved output, settled to usage). */
71
+ tokens: number;
72
+ /** Tool attempts, sub-agent dispatches included. */
73
+ tools: number;
74
+ /** Wall-clock deadline for the turn. */
75
+ durationMs: number;
76
+ }
77
+
78
+ export const DEFAULT_TURN_LIMITS: TurnLimits = {
79
+ tokens: 500_000,
80
+ tools: 120,
81
+ durationMs: 600_000,
82
+ };
83
+
84
+ /** Parse the `TURN_*` env knobs; anything unusable keeps its default. */
85
+ export function turnLimitsFromEnv(env: {
86
+ TURN_MAX_TOKENS?: number | string;
87
+ TURN_MAX_TOOL_CALLS?: number | string;
88
+ TURN_TIMEOUT_MS?: number | string;
89
+ }): TurnLimits {
90
+ const positive = (raw: number | string | undefined, fallback: number) => {
91
+ if (raw === undefined || raw === '') return fallback;
92
+ const n = Number(raw);
93
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
94
+ };
95
+ return {
96
+ tokens: positive(env.TURN_MAX_TOKENS, DEFAULT_TURN_LIMITS.tokens),
97
+ tools: positive(env.TURN_MAX_TOOL_CALLS, DEFAULT_TURN_LIMITS.tools),
98
+ durationMs: positive(env.TURN_TIMEOUT_MS, DEFAULT_TURN_LIMITS.durationMs),
99
+ };
100
+ }
101
+
102
+ export interface TurnUsage {
103
+ /** Tokens counted so far: settled usage plus in-flight reservations. */
104
+ tokens: number;
105
+ /** Tokens the provider reported (the settled part of `tokens`). */
106
+ reportedTokens: number;
107
+ modelCalls: number;
108
+ toolAttempts: number;
109
+ elapsedMs: number;
110
+ limits: TurnLimits;
111
+ }
112
+
113
+ export class TurnBudget {
114
+ private tokens = 0;
115
+ private reportedTokens = 0;
116
+ private modelCalls = 0;
117
+ private tools = 0;
118
+ private readonly startedAt: number;
119
+
120
+ constructor(
121
+ readonly limits: TurnLimits = DEFAULT_TURN_LIMITS,
122
+ private readonly now: () => number = Date.now,
123
+ ) {
124
+ this.startedAt = now();
125
+ }
126
+
127
+ /** Throws when the turn was aborted or its deadline passed. */
128
+ check(signal?: AbortSignal): void {
129
+ if (signal?.aborted) throw signal.reason ?? abortError();
130
+ if (this.now() - this.startedAt >= this.limits.durationMs)
131
+ this.fail('time', 'time');
132
+ }
133
+
134
+ /**
135
+ * Reserve a model call before it starts. Returns the reservation so the
136
+ * caller can settle it against the provider's usage once the call ends.
137
+ */
138
+ reserveModel(
139
+ estimatedInputTokens: number,
140
+ outputReserveTokens: number,
141
+ signal?: AbortSignal,
142
+ ): number {
143
+ this.check(signal);
144
+ // A NaN would poison the counter for the rest of the turn (every later
145
+ // comparison with the limit is false): an unusable number counts as 0.
146
+ const usable = (n: number): number =>
147
+ Number.isFinite(n) ? Math.max(0, n) : 0;
148
+ const reservation =
149
+ usable(estimatedInputTokens) + usable(outputReserveTokens);
150
+ if (this.tokens + reservation > this.limits.tokens)
151
+ this.fail('tokens', 'token');
152
+ this.tokens += reservation;
153
+ this.modelCalls += 1;
154
+ return reservation;
155
+ }
156
+
157
+ /** Replace a reservation with what the provider reported. */
158
+ settleModel(reservation: number, reportedTotalTokens: number): void {
159
+ if (!Number.isFinite(reportedTotalTokens) || reportedTotalTokens < 0)
160
+ return;
161
+ this.tokens += reportedTotalTokens - reservation;
162
+ this.reportedTokens += reportedTotalTokens;
163
+ }
164
+
165
+ /**
166
+ * Take a reservation back: the call is known to have cost nothing the turn
167
+ * should carry (the caller decides which failures qualify). Still counted
168
+ * as a model call.
169
+ */
170
+ releaseModel(reservation: number): void {
171
+ if (!Number.isFinite(reservation) || reservation <= 0) return;
172
+ this.tokens = Math.max(0, this.tokens - reservation);
173
+ }
174
+
175
+ reserveTool(signal?: AbortSignal): void {
176
+ this.check(signal);
177
+ if (this.tools >= this.limits.tools) this.fail('tools', 'tool-call');
178
+ this.tools += 1;
179
+ }
180
+
181
+ snapshot(): TurnUsage {
182
+ return {
183
+ tokens: this.tokens,
184
+ reportedTokens: this.reportedTokens,
185
+ modelCalls: this.modelCalls,
186
+ toolAttempts: this.tools,
187
+ elapsedMs: this.now() - this.startedAt,
188
+ limits: this.limits,
189
+ };
190
+ }
191
+
192
+ private fail(limit: HarnessLimitError['limit'], label: string): never {
193
+ throw new HarnessLimitError(
194
+ 'budget_exhausted',
195
+ limit,
196
+ `The turn reached its ${label} limit. Completed work is preserved; no further work was started.`,
197
+ );
198
+ }
199
+ }
200
+
201
+ function abortError(): Error {
202
+ const error = new Error('The turn was aborted.');
203
+ error.name = 'AbortError';
204
+ return error;
205
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The exact time of a turn rides on the message the user sent, not in the
3
+ * system prompt: the system prompt then stays identical from turn to turn
4
+ * (the provider's prompt cache keeps the whole history), and the model still
5
+ * sees when every message was sent.
6
+ *
7
+ * The note is stored as part of the message the model reads, and recorded
8
+ * verbatim in the message's `additional_kwargs` so everything a person reads
9
+ * (the transcript, a mirror) can take it off again without guessing.
10
+ */
11
+ import type { MessageContent } from '@langchain/core/messages';
12
+
13
+ /** `additional_kwargs` key holding the exact note a message was sent with. */
14
+ export const TURN_TIME_NOTE_KWARG = 'turn_time_note';
15
+
16
+ const SEPARATOR = '\n\n';
17
+
18
+ /** The content the model reads: the note first, then what the user sent. */
19
+ export function withTurnTimeNote(
20
+ content: MessageContent,
21
+ note: string,
22
+ ): MessageContent {
23
+ if (typeof content === 'string') return `${note}${SEPARATOR}${content}`;
24
+ return [{ type: 'text', text: note }, ...content];
25
+ }
26
+
27
+ /**
28
+ * The content the user sent. `note` is the value recorded under
29
+ * `TURN_TIME_NOTE_KWARG`; content that does not start with exactly that
30
+ * note (an older message, a message edited since) is returned unchanged.
31
+ */
32
+ export function stripTurnTimeNote(
33
+ content: MessageContent,
34
+ note: unknown,
35
+ ): MessageContent {
36
+ if (typeof note !== 'string' || note.length === 0) return content;
37
+ if (typeof content === 'string') {
38
+ const lead = `${note}${SEPARATOR}`;
39
+ return content.startsWith(lead) ? content.slice(lead.length) : content;
40
+ }
41
+ const [first, ...rest] = content;
42
+ return first?.type === 'text' && first.text === note ? rest : content;
43
+ }