@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.
- package/README.md +1010 -60
- 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
|
@@ -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;
|
package/routing/errors.d.ts
CHANGED
|
@@ -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
|
package/routing/executor.d.ts
CHANGED
|
@@ -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;
|
package/routing/executor.js
CHANGED
|
@@ -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 →
|
package/routing/index.d.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
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 {
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
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
|
package/routing/planner.d.ts
CHANGED
|
@@ -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. */
|
package/routing/planner.js
CHANGED
|
@@ -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
|
package/routing/policy.d.ts
CHANGED
|
@@ -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
|
package/routing/transport.d.ts
CHANGED
|
@@ -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<{
|
package/run/harness.d.ts
ADDED
|
@@ -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
package/run/index.js
ADDED
|
@@ -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
|
+
}
|
package/tools/index.d.ts
ADDED
|
@@ -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";
|