@juno-ai/bind 2.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.
Files changed (45) hide show
  1. package/README.md +1010 -60
  2. package/contracts/index.d.ts +1 -1
  3. package/contracts/index.js +1 -1
  4. package/contracts/turn.d.ts +5 -5
  5. package/index.d.ts +13 -5
  6. package/index.js +13 -5
  7. package/package.json +18 -2
  8. package/plugins/activation.d.ts +67 -0
  9. package/plugins/activation.js +61 -0
  10. package/plugins/index.d.ts +3 -0
  11. package/plugins/index.js +3 -0
  12. package/plugins/registry.d.ts +52 -0
  13. package/plugins/registry.js +54 -0
  14. package/plugins/tool.d.ts +164 -0
  15. package/plugins/tool.js +9 -0
  16. package/routing/billing-basis.d.ts +48 -0
  17. package/routing/billing-basis.js +67 -0
  18. package/routing/circuit-breaker.d.ts +2 -2
  19. package/routing/errors.d.ts +1 -1
  20. package/routing/executor.d.ts +3 -3
  21. package/routing/executor.js +1 -1
  22. package/routing/index.d.ts +11 -9
  23. package/routing/index.js +11 -9
  24. package/routing/plan-degradation.d.ts +34 -0
  25. package/routing/plan-degradation.js +38 -0
  26. package/routing/plan.d.ts +2 -2
  27. package/routing/planner.d.ts +4 -4
  28. package/routing/planner.js +1 -1
  29. package/routing/policy.d.ts +1 -1
  30. package/routing/policy.js +1 -1
  31. package/routing/transport.d.ts +2 -2
  32. package/run/harness.d.ts +94 -0
  33. package/run/harness.js +140 -0
  34. package/run/index.d.ts +2 -0
  35. package/run/index.js +2 -0
  36. package/run/tool-batch.d.ts +16 -0
  37. package/run/tool-batch.js +83 -0
  38. package/tools/index.d.ts +1 -0
  39. package/tools/index.js +1 -0
  40. package/tools/sanitize-schema.d.ts +150 -0
  41. package/tools/sanitize-schema.js +683 -0
  42. package/transcript/index.d.ts +1 -0
  43. package/transcript/index.js +1 -0
  44. package/transcript/validate.d.ts +54 -0
  45. package/transcript/validate.js +226 -0
@@ -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";
@@ -1 +1 @@
1
- export { emptyRunStats, accumulateTurn, accumulateToolCall, } from "./turn";
1
+ export { emptyRunStats, accumulateTurn, accumulateToolCall, } from "./turn.js";
@@ -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
- * NOTE: `ToolPlugin` / tool-context contracts deliberately do NOT live here
12
- * yet they are being reshaped by the single-agent consolidation work in
13
- * the host repo and move here once that lands.
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 AA/StirrupJS harness reports (`speedStats`) so numbers are
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 (see
9
- * `docs/bind.md` and the LLM Provider Routing PRD). The turn kernel and tool
10
- * contracts move here in later extraction phases.
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 (see
9
- * `docs/bind.md` and the LLM Provider Routing PRD). The turn kernel and tool
10
- * contracts move here in later extraction phases.
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": "2.0.0",
4
- "description": "Agent harness: deterministic LLM provider routing core and turn contracts (turn kernel arrives in later phases). MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
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";
@@ -0,0 +1,3 @@
1
+ export { hasContentParts, } from "./tool.js";
2
+ export { createToolRegistry, } from "./registry.js";
3
+ export { rehydrateActivation, initialActivePlugins, partitionPluginCatalog, } from "./activation.js";
@@ -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
+ }
@@ -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
+ }