@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.
- package/README.md +32 -10
- package/package.json +14 -9
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +267 -0
- package/src/channels/contract.ts +137 -0
- package/src/channels/turns.ts +270 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +198 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +1883 -264
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +523 -111
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +529 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- 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
|
+
}
|