@juno-ai/bind 1.0.0 → 3.0.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 +1009 -64
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +5 -5
- package/index.d.ts +13 -5
- package/index.js +13 -5
- package/package.json +18 -2
- package/plugins/activation.d.ts +67 -0
- package/plugins/activation.js +61 -0
- package/plugins/index.d.ts +3 -0
- package/plugins/index.js +3 -0
- package/plugins/registry.d.ts +52 -0
- package/plugins/registry.js +54 -0
- package/plugins/tool.d.ts +164 -0
- package/plugins/tool.js +9 -0
- package/routing/billing-basis.d.ts +48 -0
- package/routing/billing-basis.js +67 -0
- package/routing/circuit-breaker.d.ts +2 -2
- package/routing/errors.d.ts +1 -1
- package/routing/executor.d.ts +3 -3
- package/routing/executor.js +1 -1
- package/routing/index.d.ts +11 -9
- package/routing/index.js +11 -9
- package/routing/plan-degradation.d.ts +34 -0
- package/routing/plan-degradation.js +38 -0
- package/routing/plan.d.ts +2 -2
- package/routing/planner.d.ts +4 -4
- package/routing/planner.js +1 -1
- package/routing/policy.d.ts +1 -1
- package/routing/policy.js +1 -1
- package/routing/transport.d.ts +2 -2
- package/run/harness.d.ts +94 -0
- package/run/harness.js +140 -0
- package/run/index.d.ts +2 -0
- package/run/index.js +2 -0
- package/run/tool-batch.d.ts +16 -0
- package/run/tool-batch.js +83 -0
- package/tools/index.d.ts +1 -0
- package/tools/index.js +1 -0
- package/tools/sanitize-schema.d.ts +150 -0
- package/tools/sanitize-schema.js +683 -0
- package/transcript/index.d.ts +1 -0
- package/transcript/index.js +1 -0
- package/transcript/validate.d.ts +54 -0
- package/transcript/validate.js +226 -0
package/contracts/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { emptyRunStats, accumulateTurn, accumulateToolCall, type TranscriptMessage, type AssistantTurnMessage, type WireToolDefinition, type WireToolCall, type TurnTimings, type TurnUsage, type ModelTurnResult, type TurnFn, type StopReason, type RunStats, } from "./turn";
|
|
1
|
+
export { emptyRunStats, accumulateTurn, accumulateToolCall, type TranscriptMessage, type AssistantTurnMessage, type WireToolDefinition, type WireToolCall, type TurnTimings, type TurnUsage, type ModelTurnResult, type TurnFn, type StopReason, type RunStats, } from "./turn.js";
|
package/contracts/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { emptyRunStats, accumulateTurn, accumulateToolCall, } from "./turn";
|
|
1
|
+
export { emptyRunStats, accumulateTurn, accumulateToolCall, } from "./turn.js";
|
package/contracts/turn.d.ts
CHANGED
|
@@ -8,9 +8,9 @@ import type OpenAI from "openai";
|
|
|
8
8
|
* declarations here; no runtime import). Hosts on other client stacks (e.g.
|
|
9
9
|
* the Vercel AI SDK) adapt at the turn-function boundary.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* The tool/plugin vocabulary is a separate contract and lives in `plugins/`,
|
|
12
|
+
* not here: this module is about what a turn *is* on the wire, while that one
|
|
13
|
+
* is about what a host can register and how the harness discloses it.
|
|
14
14
|
*/
|
|
15
15
|
export type TranscriptMessage = OpenAI.ChatCompletionMessageParam;
|
|
16
16
|
export type AssistantTurnMessage = OpenAI.ChatCompletionMessage;
|
|
@@ -18,8 +18,8 @@ export type WireToolDefinition = OpenAI.ChatCompletionTool;
|
|
|
18
18
|
export type WireToolCall = OpenAI.ChatCompletionMessageToolCall;
|
|
19
19
|
/**
|
|
20
20
|
* Per-turn latency/throughput measurements. Field shapes deliberately match
|
|
21
|
-
* the metrics the
|
|
22
|
-
* directly comparable with published benchmark methodology:
|
|
21
|
+
* the metrics the StirrupJS benchmark harness reports (`speedStats`), so
|
|
22
|
+
* numbers are directly comparable with published benchmark methodology:
|
|
23
23
|
* time-to-first-token, generation wall time, and output tokens/second —
|
|
24
24
|
* plus the model-time vs tool-time split that per-task wall-clock hides.
|
|
25
25
|
*/
|
package/index.d.ts
CHANGED
|
@@ -5,9 +5,17 @@
|
|
|
5
5
|
* completion into tool effects into the next turn's context. This package is
|
|
6
6
|
* the harness that runs that chain.
|
|
7
7
|
*
|
|
8
|
-
* Current surface: the deterministic LLM provider-routing core
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Current surface: the deterministic LLM provider-routing core, the turn
|
|
9
|
+
* vocabulary, the run mechanics (deadline, coalesced heartbeat, failure
|
|
10
|
+
* classification, tool-batch pooling), transcript validation/healing, provider
|
|
11
|
+
* tool-schema sanitization, and the plugin/tool vocabulary with its registry
|
|
12
|
+
* and progressive-disclosure activation (`src/plugins/`, generic over the
|
|
13
|
+
* host's invocation context). The turn kernel arrives in a later phase; see
|
|
14
|
+
* the README for what is deliberately not here yet.
|
|
11
15
|
*/
|
|
12
|
-
export * from "./routing/index";
|
|
13
|
-
export * from "./contracts/index";
|
|
16
|
+
export * from "./routing/index.js";
|
|
17
|
+
export * from "./contracts/index.js";
|
|
18
|
+
export * from "./run/index.js";
|
|
19
|
+
export * from "./transcript/index.js";
|
|
20
|
+
export * from "./tools/index.js";
|
|
21
|
+
export * from "./plugins/index.js";
|
package/index.js
CHANGED
|
@@ -5,9 +5,17 @@
|
|
|
5
5
|
* completion into tool effects into the next turn's context. This package is
|
|
6
6
|
* the harness that runs that chain.
|
|
7
7
|
*
|
|
8
|
-
* Current surface: the deterministic LLM provider-routing core
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Current surface: the deterministic LLM provider-routing core, the turn
|
|
9
|
+
* vocabulary, the run mechanics (deadline, coalesced heartbeat, failure
|
|
10
|
+
* classification, tool-batch pooling), transcript validation/healing, provider
|
|
11
|
+
* tool-schema sanitization, and the plugin/tool vocabulary with its registry
|
|
12
|
+
* and progressive-disclosure activation (`src/plugins/`, generic over the
|
|
13
|
+
* host's invocation context). The turn kernel arrives in a later phase; see
|
|
14
|
+
* the README for what is deliberately not here yet.
|
|
11
15
|
*/
|
|
12
|
-
export * from "./routing/index";
|
|
13
|
-
export * from "./contracts/index";
|
|
16
|
+
export * from "./routing/index.js";
|
|
17
|
+
export * from "./contracts/index.js";
|
|
18
|
+
export * from "./run/index.js";
|
|
19
|
+
export * from "./transcript/index.js";
|
|
20
|
+
export * from "./tools/index.js";
|
|
21
|
+
export * from "./plugins/index.js";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juno-ai/bind",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Agent harness: deterministic LLM provider routing
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Agent harness: deterministic LLM provider routing, run mechanics, transcript healing, tool-schema sanitization, and the plugin/tool vocabulary (turn kernel arrives in a later phase). MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./index.js",
|
|
@@ -18,6 +18,22 @@
|
|
|
18
18
|
"./contracts": {
|
|
19
19
|
"types": "./contracts/index.d.ts",
|
|
20
20
|
"import": "./contracts/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./run": {
|
|
23
|
+
"types": "./run/index.d.ts",
|
|
24
|
+
"import": "./run/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./transcript": {
|
|
27
|
+
"types": "./transcript/index.d.ts",
|
|
28
|
+
"import": "./transcript/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./tools": {
|
|
31
|
+
"types": "./tools/index.d.ts",
|
|
32
|
+
"import": "./tools/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./plugins": {
|
|
35
|
+
"types": "./plugins/index.d.ts",
|
|
36
|
+
"import": "./plugins/index.js"
|
|
21
37
|
}
|
|
22
38
|
},
|
|
23
39
|
"peerDependencies": {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { PluginSummary } from "./registry.js";
|
|
2
|
+
/**
|
|
3
|
+
* Progressive tool disclosure: which plugins are loaded right now, and how a
|
|
4
|
+
* persisted set is restored across a run boundary.
|
|
5
|
+
*
|
|
6
|
+
* Two-tier disclosure exists because tool-selection accuracy degrades once a
|
|
7
|
+
* model sees more than a few dozen tools, and because every tool's JSON Schema
|
|
8
|
+
* is resent on every turn. Core plugins load unconditionally; the rest are
|
|
9
|
+
* announced as a compact catalog and activated on demand.
|
|
10
|
+
*
|
|
11
|
+
* The governing rule for restoring a persisted set is that **it is a hint, not
|
|
12
|
+
* a fact**. Names are persisted; implementations are resolved at load time, and
|
|
13
|
+
* anything that no longer resolves is dropped rather than reported as active —
|
|
14
|
+
* a plugin can be renamed, gated off, or (for a dynamically connected one) fail
|
|
15
|
+
* to reconnect between runs.
|
|
16
|
+
*/
|
|
17
|
+
/** Why a persisted activation could not be restored. */
|
|
18
|
+
export type ActivationDropReason = "unknown" | "unavailable" | "unreachable" | "error";
|
|
19
|
+
export interface DroppedActivation {
|
|
20
|
+
/** The canonicalized name that was dropped. */
|
|
21
|
+
name: string;
|
|
22
|
+
reason: ActivationDropReason;
|
|
23
|
+
}
|
|
24
|
+
export interface RehydrateResult {
|
|
25
|
+
/** Canonicalized, de-duplicated names that resolved and stay active. */
|
|
26
|
+
active: string[];
|
|
27
|
+
/** Everything that did not survive, for the host to log. */
|
|
28
|
+
dropped: DroppedActivation[];
|
|
29
|
+
}
|
|
30
|
+
export interface RehydrateOptions {
|
|
31
|
+
/** Map a persisted (possibly legacy) name to its current canonical name. */
|
|
32
|
+
canonicalizeName: (name: string) => string;
|
|
33
|
+
/**
|
|
34
|
+
* Can this name be activated on THIS run? Async because restoring a
|
|
35
|
+
* dynamically connected plugin may require re-establishing a connection —
|
|
36
|
+
* the harness owns the policy, the host owns the transport.
|
|
37
|
+
*
|
|
38
|
+
* Return `true` to keep, or a drop reason to discard.
|
|
39
|
+
*/
|
|
40
|
+
resolve: (name: string) => ActivationDropReason | true | Promise<ActivationDropReason | true>;
|
|
41
|
+
/**
|
|
42
|
+
* Observe a resolver rejection. The entry is dropped as `"error"` either way,
|
|
43
|
+
* and an observer that throws is swallowed — reporting a failure must not
|
|
44
|
+
* turn one bad name into a failed restore.
|
|
45
|
+
*/
|
|
46
|
+
onResolveError?: (name: string, error: unknown) => void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Restore a persisted activation set, re-validating every entry against
|
|
50
|
+
* current reality. Order is preserved; duplicates (including two legacy names
|
|
51
|
+
* that canonicalize to the same plugin) collapse to the first occurrence.
|
|
52
|
+
*/
|
|
53
|
+
export declare function rehydrateActivation(persisted: readonly string[], options: RehydrateOptions): Promise<RehydrateResult>;
|
|
54
|
+
/** The activation set a fresh run starts from. */
|
|
55
|
+
export declare function initialActivePlugins(corePlugins: readonly string[]): string[];
|
|
56
|
+
export interface CatalogPartition {
|
|
57
|
+
active: PluginSummary[];
|
|
58
|
+
loadable: PluginSummary[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Split the catalog into what is already active and what the model may load.
|
|
62
|
+
*
|
|
63
|
+
* This returns **data, not prose**: the wording of a catalog belongs to the
|
|
64
|
+
* host's system prompt, which is a product surface with its own voice and
|
|
65
|
+
* (in Monad's case) its own byte-for-byte prompt-cache concerns.
|
|
66
|
+
*/
|
|
67
|
+
export declare function partitionPluginCatalog(activePlugins: ReadonlySet<string>, allSummaries: readonly PluginSummary[]): CatalogPartition;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore a persisted activation set, re-validating every entry against
|
|
3
|
+
* current reality. Order is preserved; duplicates (including two legacy names
|
|
4
|
+
* that canonicalize to the same plugin) collapse to the first occurrence.
|
|
5
|
+
*/
|
|
6
|
+
export async function rehydrateActivation(persisted, options) {
|
|
7
|
+
const active = [];
|
|
8
|
+
const dropped = [];
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
for (const rawName of persisted) {
|
|
11
|
+
const name = options.canonicalizeName(rawName);
|
|
12
|
+
if (seen.has(name))
|
|
13
|
+
continue;
|
|
14
|
+
seen.add(name);
|
|
15
|
+
// A throwing resolver drops its entry rather than failing the walk. The
|
|
16
|
+
// contract is best-effort restoration, and a host resolver that reaches a
|
|
17
|
+
// database or decrypts a credential can reject transiently — one bad name
|
|
18
|
+
// must not cost the caller every other restored plugin.
|
|
19
|
+
let verdict;
|
|
20
|
+
try {
|
|
21
|
+
verdict = await options.resolve(name);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
try {
|
|
25
|
+
options.onResolveError?.(name, error);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// An observer that throws (a logger choking on a circular error, a
|
|
29
|
+
// metrics client rejecting) would otherwise defeat the very guarantee
|
|
30
|
+
// this catch exists to provide.
|
|
31
|
+
}
|
|
32
|
+
// Not `unreachable`: that reason means a dynamic plugin failed to
|
|
33
|
+
// reconnect, and a resolver can just as well throw while checking a
|
|
34
|
+
// static plugin. Reporting the failure as its own kind keeps the host's
|
|
35
|
+
// drop logging honest about what it knows.
|
|
36
|
+
verdict = "error";
|
|
37
|
+
}
|
|
38
|
+
if (verdict === true)
|
|
39
|
+
active.push(name);
|
|
40
|
+
else
|
|
41
|
+
dropped.push({ name, reason: verdict });
|
|
42
|
+
}
|
|
43
|
+
return { active, dropped };
|
|
44
|
+
}
|
|
45
|
+
/** The activation set a fresh run starts from. */
|
|
46
|
+
export function initialActivePlugins(corePlugins) {
|
|
47
|
+
return [...new Set(corePlugins)];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Split the catalog into what is already active and what the model may load.
|
|
51
|
+
*
|
|
52
|
+
* This returns **data, not prose**: the wording of a catalog belongs to the
|
|
53
|
+
* host's system prompt, which is a product surface with its own voice and
|
|
54
|
+
* (in Monad's case) its own byte-for-byte prompt-cache concerns.
|
|
55
|
+
*/
|
|
56
|
+
export function partitionPluginCatalog(activePlugins, allSummaries) {
|
|
57
|
+
return {
|
|
58
|
+
active: allSummaries.filter((summary) => activePlugins.has(summary.name)),
|
|
59
|
+
loadable: allSummaries.filter((summary) => !activePlugins.has(summary.name)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { hasContentParts, type ToolAnnotations, type ToolDef, type ToolFailureKind, type ToolPlugin, type ToolResult, type RegistrablePlugin, type SuspendDirective, } from "./tool.js";
|
|
2
|
+
export { createToolRegistry, type PluginSummary, type ToolRegistry, type ToolRegistryOptions, } from "./registry.js";
|
|
3
|
+
export { rehydrateActivation, initialActivePlugins, partitionPluginCatalog, type ActivationDropReason, type CatalogPartition, type DroppedActivation, type RehydrateOptions, type RehydrateResult, } from "./activation.js";
|
package/plugins/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { RegistrablePlugin } from "./tool.js";
|
|
2
|
+
/**
|
|
3
|
+
* A plugin registry, created as a **factory rather than a module singleton**.
|
|
4
|
+
*
|
|
5
|
+
* Monad's original registry was a module-level `Map` populated by
|
|
6
|
+
* self-registration at import time. That is workable in a long-lived Node
|
|
7
|
+
* process but wrong for a package targeting workerd isolates (module state is
|
|
8
|
+
* per-isolate and its lifetime is not the host's) and it makes tests share
|
|
9
|
+
* state implicitly. A host that wants the singleton ergonomics can still wrap
|
|
10
|
+
* one instance in a module — the choice moves to the host, which is where it
|
|
11
|
+
* belongs.
|
|
12
|
+
*/
|
|
13
|
+
export interface PluginSummary {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
isCorePlugin: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ToolRegistryOptions<TPlugin extends RegistrablePlugin> {
|
|
19
|
+
/**
|
|
20
|
+
* Always-active plugins, loaded on every run without explicit activation.
|
|
21
|
+
*/
|
|
22
|
+
corePlugins: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Compatibility aliases for historical plugin names, mapping an old name to
|
|
25
|
+
* the current canonical one. Persisted activation state stores names, so an
|
|
26
|
+
* alias is how a rename avoids silently stripping capabilities from live
|
|
27
|
+
* sessions without a data migration. Entries are permanent.
|
|
28
|
+
*/
|
|
29
|
+
aliases?: Readonly<Record<string, string>>;
|
|
30
|
+
/**
|
|
31
|
+
* Called after a plugin is registered. The seam for host-side side effects
|
|
32
|
+
* of registration (e.g. contributing a plugin's skills into a separate
|
|
33
|
+
* registry) without the harness knowing what those are.
|
|
34
|
+
*/
|
|
35
|
+
onRegister?: (plugin: TPlugin) => void;
|
|
36
|
+
}
|
|
37
|
+
export interface ToolRegistry<TPlugin extends RegistrablePlugin> {
|
|
38
|
+
register(plugin: TPlugin): void;
|
|
39
|
+
/** Resolve by name (following aliases); `undefined` when absent or unavailable. */
|
|
40
|
+
get(name: string): TPlugin | undefined;
|
|
41
|
+
/** The canonical registered name for `name`, following any alias. */
|
|
42
|
+
canonicalizeName(name: string): string;
|
|
43
|
+
corePlugins(): string[];
|
|
44
|
+
isCoreName(name: string): boolean;
|
|
45
|
+
/** Every registered plugin, including currently-unavailable ones. */
|
|
46
|
+
all(): TPlugin[];
|
|
47
|
+
/** Summaries of available plugins only. */
|
|
48
|
+
summaries(): PluginSummary[];
|
|
49
|
+
/** Names of available plugins — the single source of truth for "what exists". */
|
|
50
|
+
availableNames(): string[];
|
|
51
|
+
}
|
|
52
|
+
export declare function createToolRegistry<TPlugin extends RegistrablePlugin>(options: ToolRegistryOptions<TPlugin>): ToolRegistry<TPlugin>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function createToolRegistry(options) {
|
|
2
|
+
const plugins = new Map();
|
|
3
|
+
const core = [...new Set(options.corePlugins)];
|
|
4
|
+
const coreSet = new Set(core);
|
|
5
|
+
const aliases = options.aliases ?? {};
|
|
6
|
+
// Own-property check, not `aliases[name] ?? name`: a bare index reads
|
|
7
|
+
// through Object.prototype, so a lookup of "constructor" or "toString"
|
|
8
|
+
// returns an inherited function instead of the name. Hosts supply arbitrary
|
|
9
|
+
// alias maps, and a plugin may legitimately be named either.
|
|
10
|
+
//
|
|
11
|
+
// The `typeof` check is not redundant with the declared type: a host may
|
|
12
|
+
// build its alias map from JSON or another untyped source, and this function
|
|
13
|
+
// is a hard invariant — every caller downstream treats the result as a
|
|
14
|
+
// string. Falling back to the input beats returning a lie.
|
|
15
|
+
const canonicalizeName = (name) => {
|
|
16
|
+
if (!Object.prototype.hasOwnProperty.call(aliases, name))
|
|
17
|
+
return name;
|
|
18
|
+
const alias = aliases[name];
|
|
19
|
+
return typeof alias === "string" ? alias : name;
|
|
20
|
+
};
|
|
21
|
+
const isAvailable = (plugin) => plugin.isAvailable?.() ?? true;
|
|
22
|
+
return {
|
|
23
|
+
register(plugin) {
|
|
24
|
+
plugins.set(plugin.name, plugin);
|
|
25
|
+
options.onRegister?.(plugin);
|
|
26
|
+
},
|
|
27
|
+
get(name) {
|
|
28
|
+
const plugin = plugins.get(name) ?? plugins.get(canonicalizeName(name));
|
|
29
|
+
return plugin && isAvailable(plugin) ? plugin : undefined;
|
|
30
|
+
},
|
|
31
|
+
canonicalizeName,
|
|
32
|
+
corePlugins() {
|
|
33
|
+
return [...core];
|
|
34
|
+
},
|
|
35
|
+
isCoreName(name) {
|
|
36
|
+
return coreSet.has(name);
|
|
37
|
+
},
|
|
38
|
+
all() {
|
|
39
|
+
return [...plugins.values()];
|
|
40
|
+
},
|
|
41
|
+
summaries() {
|
|
42
|
+
return [...plugins.values()].filter(isAvailable).map((plugin) => ({
|
|
43
|
+
name: plugin.name,
|
|
44
|
+
description: plugin.description,
|
|
45
|
+
isCorePlugin: coreSet.has(plugin.name),
|
|
46
|
+
}));
|
|
47
|
+
},
|
|
48
|
+
availableNames() {
|
|
49
|
+
return [...plugins.values()]
|
|
50
|
+
.filter(isAvailable)
|
|
51
|
+
.map((plugin) => plugin.name);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The plugin/tool vocabulary — the shape of a tool, a bundle of tools, and
|
|
4
|
+
* the result of running one.
|
|
5
|
+
*
|
|
6
|
+
* Two things are deliberately NOT fixed here, because they are where hosts
|
|
7
|
+
* genuinely diverge rather than incidentally differ:
|
|
8
|
+
*
|
|
9
|
+
* - **The invocation context** (`TCtx`). A census of two production agent
|
|
10
|
+
* runtimes' contexts found ~33 fields of which exactly one (`abortSignal`)
|
|
11
|
+
* was shared; the rest are host identity (tenant, workspace, thread, role),
|
|
12
|
+
* host authorization (principal, scope clamps), or host product
|
|
13
|
+
* features. A common concrete context would be either a
|
|
14
|
+
* lowest-common-denominator or a union of two products' identity models,
|
|
15
|
+
* so the context is a type parameter and the host supplies it.
|
|
16
|
+
*
|
|
17
|
+
* - **Multimodal content parts** (`TContentPart`). The wire shape is the
|
|
18
|
+
* provider's, but building one from bytes needs host capabilities, so the
|
|
19
|
+
* harness carries the parts through without interpreting them.
|
|
20
|
+
*
|
|
21
|
+
* Hosts extend `ToolPlugin` with their own fields via ordinary interface
|
|
22
|
+
* extension (Monad adds `configuration`, `skills`, `exposeViaMcp`).
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Behavioural hints that travel with a tool on `tools/list`. Mirrors
|
|
26
|
+
* the MCP `ToolAnnotations` shape — duplicated here so plugins can
|
|
27
|
+
* declare them without importing the SDK directly.
|
|
28
|
+
*/
|
|
29
|
+
export interface ToolAnnotations {
|
|
30
|
+
/** Human-readable title for the tool. */
|
|
31
|
+
title?: string;
|
|
32
|
+
/** True if the tool only reads data. */
|
|
33
|
+
readOnlyHint?: boolean;
|
|
34
|
+
/** True if the tool may make destructive changes. Meaningful only when readOnlyHint===false. */
|
|
35
|
+
destructiveHint?: boolean;
|
|
36
|
+
/** True if calling the tool with the same args yields the same result. */
|
|
37
|
+
idempotentHint?: boolean;
|
|
38
|
+
/** True if the tool may interact with external systems. */
|
|
39
|
+
openWorldHint?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface ToolDef {
|
|
42
|
+
name: string;
|
|
43
|
+
description: string;
|
|
44
|
+
parameters: z.ZodType;
|
|
45
|
+
/**
|
|
46
|
+
* Pure post-parse canonicalization applied before run-scoped idempotency
|
|
47
|
+
* hashing and execution. Keep model-facing schemas JSON-Schema-compatible;
|
|
48
|
+
* semantic normalization such as sorting set-like arrays belongs here.
|
|
49
|
+
*/
|
|
50
|
+
normalizeArgs?: (args: unknown) => unknown;
|
|
51
|
+
/**
|
|
52
|
+
* Pre-computed JSON Schema to pass to the LLM instead of converting via
|
|
53
|
+
* `z.toJSONSchema()`. Also the seam for a host whose tools are authored as
|
|
54
|
+
* raw JSON Schema rather than zod.
|
|
55
|
+
*/
|
|
56
|
+
rawJsonSchema?: Record<string, unknown>;
|
|
57
|
+
/** Annotations forwarded on the MCP `tools/list` response. */
|
|
58
|
+
annotations?: ToolAnnotations;
|
|
59
|
+
/** If true, the dispatcher emits a periodic auto-heartbeat. */
|
|
60
|
+
supportsProgress?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* When `true`, the tool stays runnable (dispatch + `plugin.tools` lookup
|
|
63
|
+
* still resolve it) but is omitted from the model-facing catalog. Used to
|
|
64
|
+
* retire a tool from the prompt while keeping it callable for resumed
|
|
65
|
+
* sessions whose history still references it.
|
|
66
|
+
*/
|
|
67
|
+
hidden?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Optional human one-liner describing THIS call, derived from its args.
|
|
70
|
+
* MUST be pure + synchronous: `safeParse` the args with the tool's own
|
|
71
|
+
* schema, then read fields — no I/O, no context. Returns `null` to fall
|
|
72
|
+
* back to generic copy. Purity is load-bearing: it runs on the executor
|
|
73
|
+
* hot path inside a fire-and-forget emit.
|
|
74
|
+
*/
|
|
75
|
+
summarizeActivity?: (args: unknown) => string | null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Structured failure category for a failed `ToolResult`. Lets a host map a
|
|
79
|
+
* plugin failure onto its own error taxonomy — a protocol error code, an HTTP
|
|
80
|
+
* status, a retry decision — without matching on the error string, which is
|
|
81
|
+
* brittle and locale-dependent. Plugins should set it explicitly.
|
|
82
|
+
*/
|
|
83
|
+
export type ToolFailureKind = "authz" | "validation" | "not_found" | "conflict" | "external" | "system";
|
|
84
|
+
/**
|
|
85
|
+
* Human-in-the-loop suspend directive. A first-party tool returns this on a
|
|
86
|
+
* successful result to **end the run** and record its open tool-call as
|
|
87
|
+
* awaiting resolution. Two resume kinds:
|
|
88
|
+
* - `answer`: the run resumes by threading a human/external response back as
|
|
89
|
+
* the matching `role:"tool"` result. The loop therefore WITHHOLDS this
|
|
90
|
+
* call's tool message — the result is the future answer.
|
|
91
|
+
* - `wake`: a time/event resume that re-enters via a prompt and keeps the
|
|
92
|
+
* tool message.
|
|
93
|
+
* `request` is an opaque render/route payload, validated by the consumer and
|
|
94
|
+
* never inspected by the loop.
|
|
95
|
+
*/
|
|
96
|
+
export type SuspendDirective = {
|
|
97
|
+
reason: string;
|
|
98
|
+
resumeKind: "answer" | "wake";
|
|
99
|
+
request?: unknown;
|
|
100
|
+
};
|
|
101
|
+
export type ToolResult<TContentPart = unknown> = {
|
|
102
|
+
success: true;
|
|
103
|
+
data: unknown;
|
|
104
|
+
contentParts?: TContentPart[];
|
|
105
|
+
suspend?: SuspendDirective;
|
|
106
|
+
} | {
|
|
107
|
+
success: false;
|
|
108
|
+
error: string;
|
|
109
|
+
kind?: ToolFailureKind;
|
|
110
|
+
/** Optional structured recovery context safe to expose to the model. */
|
|
111
|
+
data?: unknown;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Narrow helper: does this tool result ask the executor to relay multimodal
|
|
115
|
+
* content parts alongside the textual JSON result on the next turn?
|
|
116
|
+
*/
|
|
117
|
+
export declare function hasContentParts<TContentPart>(result: ToolResult<TContentPart>): result is {
|
|
118
|
+
success: true;
|
|
119
|
+
data: unknown;
|
|
120
|
+
contentParts: TContentPart[];
|
|
121
|
+
suspend?: SuspendDirective;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* A self-contained bundle of related tools plus the metadata the catalog and
|
|
125
|
+
* the system prompt need. `execute` dispatches by tool name so a plugin can
|
|
126
|
+
* share setup across its tools.
|
|
127
|
+
*/
|
|
128
|
+
export interface ToolPlugin<TCtx, TContentPart = unknown> {
|
|
129
|
+
name: string;
|
|
130
|
+
description: string;
|
|
131
|
+
/**
|
|
132
|
+
* Optional short icon identifier a host UI may use to pick a glyph. Optional
|
|
133
|
+
* because a headless consumer has no glyph to pick; a host that renders tool
|
|
134
|
+
* activity can re-require it on its own extension of this interface.
|
|
135
|
+
*/
|
|
136
|
+
icon?: string;
|
|
137
|
+
/**
|
|
138
|
+
* Global availability gate; unavailable plugins stay registered (so they can
|
|
139
|
+
* still be listed for configuration) but do not resolve for a run.
|
|
140
|
+
*
|
|
141
|
+
* **Must be constant for the lifetime of the process or isolate.** It is
|
|
142
|
+
* re-evaluated on every catalog render — which, for a host that re-renders
|
|
143
|
+
* its system prompt on each activation, is several times per run. A value
|
|
144
|
+
* that can flip mid-run (a TTL-cached feature flag, a health probe, a live
|
|
145
|
+
* credential check) rewrites the catalog and invalidates the provider
|
|
146
|
+
* prompt-cache prefix from that byte onward for every remaining turn, and
|
|
147
|
+
* nothing will fail a test. Gate on process-stable configuration; do the
|
|
148
|
+
* liveness check inside `execute`, where a failure is a tool error.
|
|
149
|
+
*/
|
|
150
|
+
isAvailable?: () => boolean;
|
|
151
|
+
/** Instructions appended to the system prompt when this plugin is active. */
|
|
152
|
+
systemMessage?: string;
|
|
153
|
+
tools: ToolDef[];
|
|
154
|
+
execute(toolName: string, args: unknown, ctx: TCtx): Promise<ToolResult<TContentPart>>;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The structural minimum the registry and catalog need. Hosts pass their own
|
|
158
|
+
* richer plugin type; this is the constraint, not the contract.
|
|
159
|
+
*/
|
|
160
|
+
export interface RegistrablePlugin {
|
|
161
|
+
name: string;
|
|
162
|
+
description: string;
|
|
163
|
+
isAvailable?: () => boolean;
|
|
164
|
+
}
|
package/plugins/tool.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow helper: does this tool result ask the executor to relay multimodal
|
|
3
|
+
* content parts alongside the textual JSON result on the next turn?
|
|
4
|
+
*/
|
|
5
|
+
export function hasContentParts(result) {
|
|
6
|
+
return (result.success === true &&
|
|
7
|
+
Array.isArray(result.contentParts) &&
|
|
8
|
+
result.contentParts.length > 0);
|
|
9
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ProviderPricingBasis } from "./plan.js";
|
|
2
|
+
/**
|
|
3
|
+
* Synchronous provider billing-basis cost —
|
|
4
|
+
* the configured-list-rate calculation used when a provider (Azure) reports
|
|
5
|
+
* usage but not dollars. OpenRouter's provider-reported cost keeps the
|
|
6
|
+
* existing path in `llm.ts` and never goes through this function.
|
|
7
|
+
*
|
|
8
|
+
* Results are USD cents at the inference log's numeric(10, 4) precision.
|
|
9
|
+
*/
|
|
10
|
+
export interface BillingBasisUsage {
|
|
11
|
+
readonly inputTokens: number;
|
|
12
|
+
/**
|
|
13
|
+
* Provider-reported cached (discounted) input tokens. Whether this is a
|
|
14
|
+
* subset of `inputTokens` or a disjoint count is DECLARED by the binding's
|
|
15
|
+
* `cachedTokenSemantics` — never inferred from the relative sizes.
|
|
16
|
+
*/
|
|
17
|
+
readonly cachedInputTokens: number;
|
|
18
|
+
/**
|
|
19
|
+
* Provider completion-token total. Already includes reasoning tokens — do
|
|
20
|
+
* not add them a second time.
|
|
21
|
+
*/
|
|
22
|
+
readonly outputTokens: number;
|
|
23
|
+
}
|
|
24
|
+
export type BillingBasisResult = Readonly<{
|
|
25
|
+
ok: true;
|
|
26
|
+
costCents: number;
|
|
27
|
+
}> | Readonly<{
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: string;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Compute the list-rate cost in USD cents from usage and configured
|
|
33
|
+
* per-million-token rates. Rejects internally inconsistent usage rather than
|
|
34
|
+
* producing a zero-cost call: negative counts, non-integers,
|
|
35
|
+
* all-zero usage (a request always consumes prompt tokens — zeros mean the
|
|
36
|
+
* provider's accounting is broken, not that the call was free), and — under
|
|
37
|
+
* declared `"subset"` semantics — cached counts exceeding total input.
|
|
38
|
+
*
|
|
39
|
+
* The uncached-input calculation follows the binding's declared
|
|
40
|
+
* `cachedTokenSemantics`:
|
|
41
|
+
* - `"subset"` — cached ⊆ input (OpenAI): uncached = input − cached.
|
|
42
|
+
* - `"disjoint"` — cached reported alongside input (Grok via Azure AI
|
|
43
|
+
* Foundry, observed live: cached 192 vs prompt 88): uncached = input.
|
|
44
|
+
* Either way every token prices exactly once at its own rate.
|
|
45
|
+
*/
|
|
46
|
+
export declare function computeConfiguredRatesCostCents(pricing: Extract<ProviderPricingBasis, {
|
|
47
|
+
kind: "configured_token_rates";
|
|
48
|
+
}>, usage: BillingBasisUsage): BillingBasisResult;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function isUsableCount(value) {
|
|
2
|
+
return Number.isFinite(value) && Number.isInteger(value) && value >= 0;
|
|
3
|
+
}
|
|
4
|
+
function roundToScale4(value) {
|
|
5
|
+
return Math.round(value * 10_000) / 10_000;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Compute the list-rate cost in USD cents from usage and configured
|
|
9
|
+
* per-million-token rates. Rejects internally inconsistent usage rather than
|
|
10
|
+
* producing a zero-cost call: negative counts, non-integers,
|
|
11
|
+
* all-zero usage (a request always consumes prompt tokens — zeros mean the
|
|
12
|
+
* provider's accounting is broken, not that the call was free), and — under
|
|
13
|
+
* declared `"subset"` semantics — cached counts exceeding total input.
|
|
14
|
+
*
|
|
15
|
+
* The uncached-input calculation follows the binding's declared
|
|
16
|
+
* `cachedTokenSemantics`:
|
|
17
|
+
* - `"subset"` — cached ⊆ input (OpenAI): uncached = input − cached.
|
|
18
|
+
* - `"disjoint"` — cached reported alongside input (Grok via Azure AI
|
|
19
|
+
* Foundry, observed live: cached 192 vs prompt 88): uncached = input.
|
|
20
|
+
* Either way every token prices exactly once at its own rate.
|
|
21
|
+
*/
|
|
22
|
+
export function computeConfiguredRatesCostCents(pricing, usage) {
|
|
23
|
+
if (!isUsableCount(usage.inputTokens)) {
|
|
24
|
+
return { ok: false, reason: `invalid input token count: ${usage.inputTokens}` };
|
|
25
|
+
}
|
|
26
|
+
if (!isUsableCount(usage.cachedInputTokens)) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
reason: `invalid cached input token count: ${usage.cachedInputTokens}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (!isUsableCount(usage.outputTokens)) {
|
|
33
|
+
return { ok: false, reason: `invalid output token count: ${usage.outputTokens}` };
|
|
34
|
+
}
|
|
35
|
+
if (usage.inputTokens === 0 &&
|
|
36
|
+
usage.cachedInputTokens === 0 &&
|
|
37
|
+
usage.outputTokens === 0) {
|
|
38
|
+
return { ok: false, reason: "all-zero usage (provider reported no tokens)" };
|
|
39
|
+
}
|
|
40
|
+
let uncachedInputTokens;
|
|
41
|
+
switch (pricing.cachedTokenSemantics) {
|
|
42
|
+
case "subset":
|
|
43
|
+
if (usage.cachedInputTokens > usage.inputTokens) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: `cached input tokens (${usage.cachedInputTokens}) exceed input tokens ` +
|
|
47
|
+
`(${usage.inputTokens}) under declared "subset" semantics — if this ` +
|
|
48
|
+
`provider reports cached tokens alongside prompt tokens, declare ` +
|
|
49
|
+
`cachedTokenSemantics: "disjoint" on its binding`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
uncachedInputTokens = usage.inputTokens - usage.cachedInputTokens;
|
|
53
|
+
break;
|
|
54
|
+
case "disjoint":
|
|
55
|
+
uncachedInputTokens = usage.inputTokens;
|
|
56
|
+
break;
|
|
57
|
+
default: {
|
|
58
|
+
const _exhaustive = pricing.cachedTokenSemantics;
|
|
59
|
+
throw new Error(`unknown cached-token semantics: ${JSON.stringify(_exhaustive)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const usd = (uncachedInputTokens * pricing.inputUsdPerM +
|
|
63
|
+
usage.cachedInputTokens * pricing.cachedInputUsdPerM +
|
|
64
|
+
usage.outputTokens * pricing.outputUsdPerM) /
|
|
65
|
+
1_000_000;
|
|
66
|
+
return { ok: true, costCents: roundToScale4(usd * 100) };
|
|
67
|
+
}
|