@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.
Files changed (45) hide show
  1. package/README.md +1009 -64
  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,5 +1,5 @@
1
- import type { ProviderId } from "./canonical-model";
2
- import type { CredentialSource } from "./plan";
1
+ import type { ProviderId } from "./canonical-model.js";
2
+ import type { CredentialSource } from "./plan.js";
3
3
  /**
4
4
  * Circuit-breaker state is keyed by (provider, invocation model, credential
5
5
  * source) — LLM Provider Routing PRD §7.2. State is process-local in v1;
@@ -1,4 +1,4 @@
1
- import type { CanonicalModelId, ProviderId } from "./canonical-model";
1
+ import type { CanonicalModelId, ProviderId } from "./canonical-model.js";
2
2
  /**
3
3
  * The normative attempt-order cursor (LLM Provider Routing PRD §5.2):
4
4
  * structured-output attempt → model stage → provider candidate → same-endpoint
@@ -1,6 +1,6 @@
1
- import type { RouteCandidate, RoutePlan } from "./plan";
2
- import { type InferenceAttemptError, type RouteAttemptCursor } from "./errors";
3
- import type { RouteCircuitBreaker } from "./circuit-breaker";
1
+ import type { RouteCandidate, RoutePlan } from "./plan.js";
2
+ import { type InferenceAttemptError, type RouteAttemptCursor } from "./errors.js";
3
+ import type { RouteCircuitBreaker } from "./circuit-breaker.js";
4
4
  export type AttemptOutcome<T> = Readonly<{
5
5
  kind: "success";
6
6
  value: T;
@@ -1,4 +1,4 @@
1
- import { failureDisposition, isRetriableAttemptError, } from "./errors";
1
+ import { failureDisposition, isRetriableAttemptError, } from "./errors.js";
2
2
  /**
3
3
  * Drive one structured-output attempt over a frozen route plan with the
4
4
  * normative loop nesting (PRD §5.2): model stage → provider candidate →
@@ -1,9 +1,11 @@
1
- export { canonicalModelIdSchema, providerIdSchema, type CanonicalModelId, type ProviderId, } from "./canonical-model";
2
- export { ROUTE_POLICY_VERSION, providerPolicySchema, modelRouteRegistrySchema, parseModelRouteRegistry, resolveProviderPolicy, policyProviderOrder, type RoutePolicyVersion, type ProviderPolicy, type ModelRouteRegistry, } from "./policy";
3
- export { INFERENCE_CAPABILITIES, planIsEmpty, type InferenceCapability, type CredentialSource, type InferenceRequirements, type CachedTokenSemantics, type ProviderPricingBasis, type RouteCandidate, type RouteStage, type RoutePlan, type RouteSkip, } from "./plan";
4
- export { type TransportAvailability, type CandidateResolution, type PlannerTransport, } from "./transport";
5
- export { buildRoutePlan, type RoutePlanRequest, type RoutePlanResult } from "./planner";
6
- export { fallbackKindOfCursor } from "./executor";
7
- export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, type RouteAttemptCursor, type AttemptTarget, type HttpFailureCategory, type InferenceAttemptError, type FailureDisposition, type BreakerEffect, } from "./errors";
8
- export { createCircuitBreaker, type BreakerKey, type CircuitBreakerOptions, type EndpointAdmission, type RecordFailureOptions, type RouteCircuitBreaker, } from "./circuit-breaker";
9
- export { executeRoutePlan, type AttemptOutcome, type AttemptFn, type FallbackKind, type ExecutePlanOptions, type RouteExecutionResult, } from "./executor";
1
+ export { canonicalModelIdSchema, providerIdSchema, type CanonicalModelId, type ProviderId, } from "./canonical-model.js";
2
+ export { ROUTE_POLICY_VERSION, providerPolicySchema, modelRouteRegistrySchema, parseModelRouteRegistry, resolveProviderPolicy, policyProviderOrder, type RoutePolicyVersion, type ProviderPolicy, type ModelRouteRegistry, } from "./policy.js";
3
+ export { INFERENCE_CAPABILITIES, planIsEmpty, type InferenceCapability, type CredentialSource, type InferenceRequirements, type CachedTokenSemantics, type ProviderPricingBasis, type RouteCandidate, type RouteStage, type RoutePlan, type RouteSkip, } from "./plan.js";
4
+ export { type TransportAvailability, type CandidateResolution, type PlannerTransport, } from "./transport.js";
5
+ export { buildRoutePlan, type RoutePlanRequest, type RoutePlanResult } from "./planner.js";
6
+ export { buildRoutePlanWithConfigDegradation, type DegradedStage, type DegradedPlanResult, } from "./plan-degradation.js";
7
+ export { computeConfiguredRatesCostCents, type BillingBasisUsage, type BillingBasisResult, } from "./billing-basis.js";
8
+ export { fallbackKindOfCursor } from "./executor.js";
9
+ export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, type RouteAttemptCursor, type AttemptTarget, type HttpFailureCategory, type InferenceAttemptError, type FailureDisposition, type BreakerEffect, } from "./errors.js";
10
+ export { createCircuitBreaker, type BreakerKey, type CircuitBreakerOptions, type EndpointAdmission, type RecordFailureOptions, type RouteCircuitBreaker, } from "./circuit-breaker.js";
11
+ export { executeRoutePlan, type AttemptOutcome, type AttemptFn, type FallbackKind, type ExecutePlanOptions, type RouteExecutionResult, } from "./executor.js";
package/routing/index.js CHANGED
@@ -1,9 +1,11 @@
1
- export { canonicalModelIdSchema, providerIdSchema, } from "./canonical-model";
2
- export { ROUTE_POLICY_VERSION, providerPolicySchema, modelRouteRegistrySchema, parseModelRouteRegistry, resolveProviderPolicy, policyProviderOrder, } from "./policy";
3
- export { INFERENCE_CAPABILITIES, planIsEmpty, } from "./plan";
4
- export {} from "./transport";
5
- export { buildRoutePlan } from "./planner";
6
- export { fallbackKindOfCursor } from "./executor";
7
- export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, } from "./errors";
8
- export { createCircuitBreaker, } from "./circuit-breaker";
9
- export { executeRoutePlan, } from "./executor";
1
+ export { canonicalModelIdSchema, providerIdSchema, } from "./canonical-model.js";
2
+ export { ROUTE_POLICY_VERSION, providerPolicySchema, modelRouteRegistrySchema, parseModelRouteRegistry, resolveProviderPolicy, policyProviderOrder, } from "./policy.js";
3
+ export { INFERENCE_CAPABILITIES, planIsEmpty, } from "./plan.js";
4
+ export {} from "./transport.js";
5
+ export { buildRoutePlan } from "./planner.js";
6
+ export { buildRoutePlanWithConfigDegradation, } from "./plan-degradation.js";
7
+ export { computeConfiguredRatesCostCents, } from "./billing-basis.js";
8
+ export { fallbackKindOfCursor } from "./executor.js";
9
+ export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, } from "./errors.js";
10
+ export { createCircuitBreaker, } from "./circuit-breaker.js";
11
+ export { executeRoutePlan, } from "./executor.js";
@@ -0,0 +1,34 @@
1
+ import type { CanonicalModelId } from "./canonical-model.js";
2
+ import type { RouteSkip } from "./plan.js";
3
+ import { type RoutePlanRequest, type RoutePlanResult } from "./planner.js";
4
+ import { type ProviderPolicy } from "./policy.js";
5
+ /**
6
+ * Configuration-degradation wrapper around the harness planner.
7
+ *
8
+ * Policy: even when a provider has priority over the default (an explicit
9
+ * `only` fence or an ordered policy naming it first), **missing keys or
10
+ * configuration must never make inference unavailable** — the call degrades
11
+ * to the default provider and the caller logs an error. This keeps
12
+ * development environments that hold only an OpenRouter key fully working
13
+ * while a checked-in policy prioritizes (say) Azure, and keeps production
14
+ * serving through a provider-config regression instead of hard-failing.
15
+ *
16
+ * Scope: PLAN-TIME emptiness only — a stage with zero attemptable candidates
17
+ * (transport unconfigured/invalid, no binding for the model, capability
18
+ * mismatch). Runtime failures keep the fence semantics: a configured `only`
19
+ * provider that errors at request time is never retried through another
20
+ * provider; the executor's cross-provider traversal applies only
21
+ * within the plan the policy allowed.
22
+ */
23
+ export interface DegradedStage {
24
+ readonly model: CanonicalModelId;
25
+ /** The policy that produced an empty stage. */
26
+ readonly fromPolicy: ProviderPolicy;
27
+ /** Plan skips recorded for this model under the original policy. */
28
+ readonly skips: readonly RouteSkip[];
29
+ }
30
+ export interface DegradedPlanResult extends RoutePlanResult {
31
+ /** Stages rebuilt under the degraded default; empty when nothing degraded. */
32
+ readonly degraded: readonly DegradedStage[];
33
+ }
34
+ export declare function buildRoutePlanWithConfigDegradation(request: RoutePlanRequest, degradedPolicy: ProviderPolicy): DegradedPlanResult;
@@ -0,0 +1,38 @@
1
+ import { buildRoutePlan, } from "./planner.js";
2
+ import { policyProviderOrder } from "./policy.js";
3
+ function policiesEqual(a, b) {
4
+ const aOrder = policyProviderOrder(a);
5
+ const bOrder = policyProviderOrder(b);
6
+ return (a.mode === b.mode &&
7
+ aOrder.length === bOrder.length &&
8
+ aOrder.every((provider, index) => provider === bOrder[index]));
9
+ }
10
+ export function buildRoutePlanWithConfigDegradation(request, degradedPolicy) {
11
+ const first = buildRoutePlan(request);
12
+ const emptyStagePolicies = new Map();
13
+ for (const stage of first.plan.stages) {
14
+ if (stage.candidates.length === 0 &&
15
+ !policiesEqual(stage.policy, degradedPolicy)) {
16
+ emptyStagePolicies.set(stage.canonicalModelId, stage.policy);
17
+ }
18
+ }
19
+ if (emptyStagePolicies.size === 0) {
20
+ return { plan: first.plan, skips: first.skips, degraded: [] };
21
+ }
22
+ // Deterministic rebuild: identical inputs except the empty stages' models
23
+ // now resolve to the degraded default policy. If the default provider is
24
+ // ALSO unavailable (e.g. no OpenRouter key at all) the rebuilt stage stays
25
+ // empty and the caller's no-viable-endpoint handling applies unchanged.
26
+ const second = buildRoutePlan({
27
+ ...request,
28
+ policyFor: (model) => emptyStagePolicies.get(model) !== undefined
29
+ ? degradedPolicy
30
+ : request.policyFor(model),
31
+ });
32
+ const degraded = [...emptyStagePolicies.entries()].map(([model, fromPolicy]) => ({
33
+ model,
34
+ fromPolicy,
35
+ skips: first.skips.filter((skip) => skip.canonicalModelId === model),
36
+ }));
37
+ return { plan: second.plan, skips: second.skips, degraded };
38
+ }
package/routing/plan.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { CanonicalModelId, ProviderId } from "./canonical-model";
2
- import type { ProviderPolicy, RoutePolicyVersion } from "./policy";
1
+ import type { CanonicalModelId, ProviderId } from "./canonical-model.js";
2
+ import type { ProviderPolicy, RoutePolicyVersion } from "./policy.js";
3
3
  /**
4
4
  * Capabilities the planner checks before any network request (LLM Provider
5
5
  * Routing PRD §5.1/§5.3). `max_completion_tokens` is modeled as a capability
@@ -1,7 +1,7 @@
1
- import type { CanonicalModelId, ProviderId } from "./canonical-model";
2
- import { type ProviderPolicy } from "./policy";
3
- import type { InferenceRequirements, RoutePlan, RouteSkip } from "./plan";
4
- import type { PlannerTransport } from "./transport";
1
+ import type { CanonicalModelId, ProviderId } from "./canonical-model.js";
2
+ import { type ProviderPolicy } from "./policy.js";
3
+ import type { InferenceRequirements, RoutePlan, RouteSkip } from "./plan.js";
4
+ import type { PlannerTransport } from "./transport.js";
5
5
  export interface RoutePlanRequest {
6
6
  readonly primaryModel: CanonicalModelId;
7
7
  /** A distinct fallback canonical model, or null. Equal to primary is ignored. */
@@ -1,4 +1,4 @@
1
- import { ROUTE_POLICY_VERSION, policyProviderOrder, } from "./policy";
1
+ import { ROUTE_POLICY_VERSION, policyProviderOrder, } from "./policy.js";
2
2
  /**
3
3
  * Deterministically expand a canonical primary/fallback selection into an
4
4
  * immutable, ordered route plan (LLM Provider Routing PRD §5.1). The only
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { type CanonicalModelId, type ProviderId } from "./canonical-model";
2
+ import { type CanonicalModelId, type ProviderId } from "./canonical-model.js";
3
3
  /**
4
4
  * Policy order and semantics are versioned; the version string travels into
5
5
  * telemetry (LLM Provider Routing PRD §4.2) so a routing-behavior change is
package/routing/policy.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { canonicalModelIdSchema, providerIdSchema, } from "./canonical-model";
2
+ import { canonicalModelIdSchema, providerIdSchema, } from "./canonical-model.js";
3
3
  /**
4
4
  * Policy order and semantics are versioned; the version string travels into
5
5
  * telemetry (LLM Provider Routing PRD §4.2) so a routing-behavior change is
@@ -1,5 +1,5 @@
1
- import type { CanonicalModelId, ProviderId } from "./canonical-model";
2
- import type { InferenceRequirements, RouteCandidate, RouteSkip } from "./plan";
1
+ import type { CanonicalModelId, ProviderId } from "./canonical-model.js";
2
+ import type { InferenceRequirements, RouteCandidate, RouteSkip } from "./plan.js";
3
3
  export type TransportAvailability = Readonly<{
4
4
  available: true;
5
5
  }> | Readonly<{
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Cross-cutting run mechanics for an agent loop:
3
+ *
4
+ * 1. a **wall-clock deadline** that aborts a hung run (thread its signal into
5
+ * every model and tool call, and re-check at the top of each iteration);
6
+ * 2. a **coalesced progress heartbeat** for hosts that record liveness;
7
+ * 3. a **failure classifier** mapping a thrown run to a terminal status
8
+ * (`timed_out` vs `failed`).
9
+ *
10
+ * Any driver of the loop needs all three, and reimplementing them per driver
11
+ * is how one ends up unbounded: a loop whose abort signal never fires holds its
12
+ * worker slot until an external reaper notices, which is a stall the user sees
13
+ * as an agent that never answers.
14
+ *
15
+ * This module owns no persistence and makes no model calls — it is pure
16
+ * mechanics, so a small consumer can use it without pulling in a loop's
17
+ * transitive graph.
18
+ */
19
+ /** Thrown by {@link RunDeadline.throwIfTimedOut} once the wall-clock budget is
20
+ * exhausted. Distinct from a caller-driven cancellation so callers can map it
21
+ * to a `timed_out` terminal status (or simply read {@link RunDeadline.timedOut}). */
22
+ export declare class RunTimeoutError extends Error {
23
+ readonly timeoutMs: number;
24
+ constructor(timeoutMs: number, label: string);
25
+ }
26
+ /**
27
+ * Call `unref()` on a timer when the runtime exposes it (Node/Bun) so a
28
+ * forgotten clear can't hold the event loop open; a no-op under the DOM `number`
29
+ * timer type. Feature-detected rather than `as`-cast to keep type safety.
30
+ */
31
+ export declare function unrefTimer(timer: ReturnType<typeof setTimeout>): void;
32
+ /**
33
+ * A wall-clock budget for a single run. Backed by an `AbortController` that
34
+ * fires after `timeoutMs`; `signal` is threaded into LLM/tool calls so an
35
+ * in-flight upstream request is actually torn down on timeout (not merely
36
+ * abandoned). `timedOut` reflects *this* deadline firing only — it stays false
37
+ * when a combined external (cancellation) signal aborts — so it is a reliable
38
+ * basis for classifying a `timed_out` outcome regardless of which error shape
39
+ * surfaced (a `RunTimeoutError` from {@link throwIfTimedOut} or an `AbortError`
40
+ * from a torn-down `callLLM` stream).
41
+ */
42
+ export interface RunDeadline {
43
+ /** The deadline's own abort signal. Combine with a cancellation signal via
44
+ * {@link withExternal} before handing to `callLLM`. */
45
+ readonly signal: AbortSignal;
46
+ /** True once this deadline's timer has fired. Unaffected by external signals. */
47
+ readonly timedOut: boolean;
48
+ /** Throw {@link RunTimeoutError} if the budget is exhausted; no-op otherwise.
49
+ * Call at the top of each loop iteration. */
50
+ throwIfTimedOut(): void;
51
+ /** Combine this deadline with an optional external signal (e.g. a DB-backed
52
+ * cancellation controller). Returns the deadline's own signal when no
53
+ * external signal is given. */
54
+ withExternal(external?: AbortSignal | null): AbortSignal;
55
+ /** Clear the underlying timer. Idempotent; call in a `finally`. */
56
+ dispose(): void;
57
+ }
58
+ export declare function createRunDeadline(opts: {
59
+ timeoutMs: number;
60
+ label?: string;
61
+ }): RunDeadline;
62
+ /**
63
+ * Classify a run that ended by throwing into its terminal status. A run whose
64
+ * deadline fired — or whose error is a {@link RunTimeoutError} — is `timed_out`;
65
+ * anything else is `failed`. Checking the error too makes the result robust to
66
+ * the surfaced shape (an `AbortError` from a torn-down stream leaves
67
+ * `deadline.timedOut` true; a `RunTimeoutError` thrown between iterations is
68
+ * caught directly even if a combined-signal edge left `timedOut` unread).
69
+ *
70
+ * Caller-driven cancellation is a distinct outcome and must be handled *before*
71
+ * calling this (the executor special-cases `AgentRunCancelledError`); the
72
+ * one-off/onboarding path has no cancellation, so this split is complete there.
73
+ */
74
+ export declare function classifyRunFailure(deadline: RunDeadline, error?: unknown): "timed_out" | "failed";
75
+ /** A progress heartbeat that collapses bursts of calls into at most one flush
76
+ * per `coalesceMs` window (a `force` beat always flushes). */
77
+ export interface CoalescedHeartbeat {
78
+ beat(opts?: {
79
+ force?: boolean;
80
+ }): Promise<void>;
81
+ }
82
+ /**
83
+ * Build a coalesced heartbeat. A run with N concurrent tool calls would
84
+ * otherwise fire N near-simultaneous run-row UPDATEs; coalescing collapses
85
+ * them to one per window while a `force: true` beat (used at the end of each
86
+ * iteration) guarantees a bump within the reaper's stale threshold. `flush`
87
+ * owns the actual write; a flush failure is reported to `onError` and
88
+ * swallowed so a transient DB hiccup never aborts the run.
89
+ */
90
+ export declare function createCoalescedHeartbeat(opts: {
91
+ coalesceMs: number;
92
+ flush: () => Promise<void>;
93
+ onError?: (err: unknown) => void;
94
+ }): CoalescedHeartbeat;
package/run/harness.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Cross-cutting run mechanics for an agent loop:
3
+ *
4
+ * 1. a **wall-clock deadline** that aborts a hung run (thread its signal into
5
+ * every model and tool call, and re-check at the top of each iteration);
6
+ * 2. a **coalesced progress heartbeat** for hosts that record liveness;
7
+ * 3. a **failure classifier** mapping a thrown run to a terminal status
8
+ * (`timed_out` vs `failed`).
9
+ *
10
+ * Any driver of the loop needs all three, and reimplementing them per driver
11
+ * is how one ends up unbounded: a loop whose abort signal never fires holds its
12
+ * worker slot until an external reaper notices, which is a stall the user sees
13
+ * as an agent that never answers.
14
+ *
15
+ * This module owns no persistence and makes no model calls — it is pure
16
+ * mechanics, so a small consumer can use it without pulling in a loop's
17
+ * transitive graph.
18
+ */
19
+ /** Thrown by {@link RunDeadline.throwIfTimedOut} once the wall-clock budget is
20
+ * exhausted. Distinct from a caller-driven cancellation so callers can map it
21
+ * to a `timed_out` terminal status (or simply read {@link RunDeadline.timedOut}). */
22
+ export class RunTimeoutError extends Error {
23
+ timeoutMs;
24
+ constructor(timeoutMs, label) {
25
+ super(`${label} exceeded ${formatDuration(timeoutMs)} timeout`);
26
+ this.timeoutMs = timeoutMs;
27
+ this.name = "RunTimeoutError";
28
+ }
29
+ }
30
+ function formatDuration(ms) {
31
+ if (ms % 60_000 === 0) {
32
+ const m = ms / 60_000;
33
+ return `${m} ${m === 1 ? "minute" : "minutes"}`;
34
+ }
35
+ // Floor at 1 so a sub-second budget (e.g. a 50ms test deadline) never reads
36
+ // "0 seconds".
37
+ const s = Math.max(1, Math.round(ms / 1000));
38
+ return `${s} ${s === 1 ? "second" : "seconds"}`;
39
+ }
40
+ /**
41
+ * Call `unref()` on a timer when the runtime exposes it (Node/Bun) so a
42
+ * forgotten clear can't hold the event loop open; a no-op under the DOM `number`
43
+ * timer type. Feature-detected rather than `as`-cast to keep type safety.
44
+ */
45
+ export function unrefTimer(timer) {
46
+ if (typeof timer === "object" &&
47
+ timer !== null &&
48
+ typeof timer.unref === "function") {
49
+ timer.unref();
50
+ }
51
+ }
52
+ export function createRunDeadline(opts) {
53
+ const label = opts.label ?? "agent run";
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
56
+ // Unref so a forgotten dispose() (e.g. in a test) can't hold the event loop
57
+ // open; the timer is always cleared on the real run path.
58
+ unrefTimer(timer);
59
+ return {
60
+ signal: controller.signal,
61
+ get timedOut() {
62
+ return controller.signal.aborted;
63
+ },
64
+ throwIfTimedOut() {
65
+ if (controller.signal.aborted) {
66
+ throw new RunTimeoutError(opts.timeoutMs, label);
67
+ }
68
+ },
69
+ withExternal(external) {
70
+ if (!external)
71
+ return controller.signal;
72
+ return AbortSignal.any([external, controller.signal]);
73
+ },
74
+ dispose() {
75
+ clearTimeout(timer);
76
+ },
77
+ };
78
+ }
79
+ /**
80
+ * Classify a run that ended by throwing into its terminal status. A run whose
81
+ * deadline fired — or whose error is a {@link RunTimeoutError} — is `timed_out`;
82
+ * anything else is `failed`. Checking the error too makes the result robust to
83
+ * the surfaced shape (an `AbortError` from a torn-down stream leaves
84
+ * `deadline.timedOut` true; a `RunTimeoutError` thrown between iterations is
85
+ * caught directly even if a combined-signal edge left `timedOut` unread).
86
+ *
87
+ * Caller-driven cancellation is a distinct outcome and must be handled *before*
88
+ * calling this (the executor special-cases `AgentRunCancelledError`); the
89
+ * one-off/onboarding path has no cancellation, so this split is complete there.
90
+ */
91
+ export function classifyRunFailure(deadline, error) {
92
+ if (deadline.timedOut || error instanceof RunTimeoutError)
93
+ return "timed_out";
94
+ return "failed";
95
+ }
96
+ /**
97
+ * Build a coalesced heartbeat. A run with N concurrent tool calls would
98
+ * otherwise fire N near-simultaneous run-row UPDATEs; coalescing collapses
99
+ * them to one per window while a `force: true` beat (used at the end of each
100
+ * iteration) guarantees a bump within the reaper's stale threshold. `flush`
101
+ * owns the actual write; a flush failure is reported to `onError` and
102
+ * swallowed so a transient DB hiccup never aborts the run.
103
+ */
104
+ export function createCoalescedHeartbeat(opts) {
105
+ let lastAt = 0;
106
+ let inFlight = null;
107
+ let queued = false;
108
+ // Drain queued flushes ONE AT A TIME, so a coalesced background beat and a
109
+ // forced end-of-iteration beat can never run `flush()` concurrently — two
110
+ // overlapping DB writes could otherwise resolve out of order and let an older
111
+ // token/cost snapshot overwrite a newer one. `flush` reads live state at call
112
+ // time, so each drained flush still writes the latest values, and a request
113
+ // enqueued during the prior await is picked up on the next loop turn.
114
+ const drain = async () => {
115
+ while (queued) {
116
+ queued = false;
117
+ try {
118
+ await opts.flush();
119
+ }
120
+ catch (err) {
121
+ opts.onError?.(err);
122
+ }
123
+ }
124
+ inFlight = null;
125
+ };
126
+ return {
127
+ async beat(o) {
128
+ const now = Date.now();
129
+ if (!o?.force && now - lastAt < opts.coalesceMs)
130
+ return;
131
+ lastAt = now;
132
+ queued = true;
133
+ if (!inFlight)
134
+ inFlight = drain();
135
+ // Await the drain so a forced beat returns only after the latest write has
136
+ // landed — the reaper-liveness / token-flush guarantee callers rely on.
137
+ await inFlight;
138
+ },
139
+ };
140
+ }
package/run/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, type RunDeadline, type CoalescedHeartbeat, } from "./harness.js";
2
+ export { runToolCallsPooledByTool } from "./tool-batch.js";
package/run/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, } from "./harness.js";
2
+ export { runToolCallsPooledByTool } from "./tool-batch.js";
@@ -0,0 +1,16 @@
1
+ import type OpenAI from "openai";
2
+ /**
3
+ * Run a batch of tool calls concurrently with per-tool-name pooling. Calls
4
+ * to different tools fan out fully; calls to the *same* tool are capped at
5
+ * `PER_TOOL_POOL_SIZE` so a batch of many calls to one downstream service does
6
+ * not overwhelm it. Results are
7
+ * returned in the same order as `calls`, each as a `PromiseSettledResult`
8
+ * so the caller can synthesize error tool messages for any rejections
9
+ * (every `tool_call_id` in the assistant message still needs a response).
10
+ *
11
+ * Generic over the per-call outcome: the pooling and ordering policy is
12
+ * harness-owned, while the shape a host derives from a completed call (which
13
+ * plugin activated, whether the run should compact, a suspend directive, …)
14
+ * stays with the host.
15
+ */
16
+ export declare function runToolCallsPooledByTool<TOutcome>(calls: OpenAI.ChatCompletionMessageToolCall[], run: (tc: OpenAI.ChatCompletionMessageToolCall) => Promise<TOutcome>): Promise<PromiseSettledResult<TOutcome>[]>;
@@ -0,0 +1,83 @@
1
+ // Per-tool concurrency ceiling inside a single LLM tool-call batch. A model
2
+ // response can request many calls to the same tool; running them all at once
3
+ // would hammer whatever it talks to (a search backend, a database, a
4
+ // third-party API). Calls to *different* tools still fan out fully — the cap
5
+ // is applied per tool name, not globally.
6
+ const PER_TOOL_POOL_SIZE = 5;
7
+ /**
8
+ * The name a batch pools on. `function` and `custom` tool calls both carry a
9
+ * name, on different fields; anything the wire union grows later falls back to
10
+ * its type, which pools all such calls together rather than guessing.
11
+ */
12
+ function poolKey(call) {
13
+ switch (call.type) {
14
+ case "function":
15
+ return call.function.name;
16
+ case "custom":
17
+ return `__custom__:${call.custom.name}`;
18
+ default: {
19
+ // Not `never`: this union is a third party's, and a new member must not
20
+ // become a type error in a consumer that never sees one.
21
+ const unknownCall = call;
22
+ return `__${unknownCall.type ?? "unknown"}__`;
23
+ }
24
+ }
25
+ }
26
+ /**
27
+ * Run a batch of tool calls concurrently with per-tool-name pooling. Calls
28
+ * to different tools fan out fully; calls to the *same* tool are capped at
29
+ * `PER_TOOL_POOL_SIZE` so a batch of many calls to one downstream service does
30
+ * not overwhelm it. Results are
31
+ * returned in the same order as `calls`, each as a `PromiseSettledResult`
32
+ * so the caller can synthesize error tool messages for any rejections
33
+ * (every `tool_call_id` in the assistant message still needs a response).
34
+ *
35
+ * Generic over the per-call outcome: the pooling and ordering policy is
36
+ * harness-owned, while the shape a host derives from a completed call (which
37
+ * plugin activated, whether the run should compact, a suspend directive, …)
38
+ * stays with the host.
39
+ */
40
+ export async function runToolCallsPooledByTool(calls, run) {
41
+ if (calls.length === 0)
42
+ return [];
43
+ const results = new Array(calls.length);
44
+ const groups = new Map();
45
+ for (let i = 0; i < calls.length; i++) {
46
+ const tc = calls[i];
47
+ // Pool key is the tool name, wherever the wire union puts it — `custom`
48
+ // tool calls carry theirs on a different field, and pooling every one of
49
+ // them together would serialize unrelated tools behind each other. A future
50
+ // variant with no name at all falls back to its type; the `__` fencing
51
+ // keeps either synthetic key from colliding with a real tool name.
52
+ const key = poolKey(tc);
53
+ let arr = groups.get(key);
54
+ if (!arr) {
55
+ arr = [];
56
+ groups.set(key, arr);
57
+ }
58
+ arr.push(i);
59
+ }
60
+ await Promise.all(Array.from(groups.values()).map(async (indices) => {
61
+ // `cursor++` in single-threaded JS is atomic between awaits — no lock
62
+ // needed. Workers race for the next index; when cursor exceeds the
63
+ // group size the worker returns.
64
+ let cursor = 0;
65
+ const workerCount = Math.min(PER_TOOL_POOL_SIZE, indices.length);
66
+ await Promise.all(Array.from({ length: workerCount }, async () => {
67
+ while (true) {
68
+ const pos = cursor++;
69
+ if (pos >= indices.length)
70
+ return;
71
+ const callIdx = indices[pos];
72
+ try {
73
+ const value = await run(calls[callIdx]);
74
+ results[callIdx] = { status: "fulfilled", value };
75
+ }
76
+ catch (reason) {
77
+ results[callIdx] = { status: "rejected", reason };
78
+ }
79
+ }
80
+ }));
81
+ }));
82
+ return results;
83
+ }
@@ -0,0 +1 @@
1
+ export { sanitizeToolSchema } from "./sanitize-schema.js";
package/tools/index.js ADDED
@@ -0,0 +1 @@
1
+ export { sanitizeToolSchema } from "./sanitize-schema.js";