import { DataStore } from '@voltro/database'; import { Effect } from 'effect'; import { PluginMediaPersistMetadata } from '@voltro/protocol'; import { Runtime } from 'effect'; import { Schema } from 'effect'; import { StepRetryPolicy } from '@voltro/workflow'; /** The provider-call retry default for an INLINE call. * * A rate limit and a 5xx are how a model call fails, and both are transient. * Left to itself an unretried call fails the whole durable run for a condition * that resolves in two seconds. Overridable per step; `retry: { maxAttempts: * 1 }` opts out. An OFFLOADED call retries in the dispatcher instead, on the * same rules — see `decideInferenceFailure`. */ export declare const AI_STEP_RETRY: StepRetryPolicy; export declare class AiBudgetMisuseError extends Error { constructor(stepName: string); } /** Provider-specific knobs forwarded verbatim to the Vercel AI SDK's * `providerOptions` on generateText / streamText / generateObject. The outer * key is the provider id, the inner record its options. The headline use is * REASONING EFFORT on reasoning models — `gpt-5.x` defaults to a high effort * that costs many seconds per call even for a one-line answer, so a routing/ * help agent should pin it low: * * providerOptions: { openai: { reasoningEffort: 'low' } } // or 'minimal' * providerOptions: { anthropic: { thinking: { type: 'disabled' } } } * * Shape matches the SDK's `ProviderOptions` (Record>). The value is the standard JSON value (mutable object + array, * so it stays assignable to the SDK's `SharedV2ProviderOptions` under * `exactOptionalPropertyTypes`) — NOT `unknown`, which the SDK rejects. */ declare type AiJsonValue = null | boolean | number | string | { [key: string]: AiJsonValue; } | AiJsonValue[]; export declare const aiObjectStep: (options: AiObjectStepOptions) => Effect.Effect; export declare interface AiObjectStepOptions extends AiStepBaseOptions { readonly schema: Schema.Schema; } export declare class AiOffloadError extends Error { readonly attempts: number; /** `failed` — the call was performed and gave up. `unknown` — the worker * that submitted it was lost and the provider cannot be asked: the job may * have produced (and charged for) its artifact. A media job only. */ readonly outcome: 'failed' | 'unknown'; constructor(stepName: string, message: string, attempts: number, outcome?: 'failed' | 'unknown'); } export declare class AiOffloadMisuseError extends Error { constructor(stepName: string); } declare type AiProviderOptions = Record>; /** * A text completion as a durable step. * * ```ts * const summary = yield* aiStep({ * name: 'summarise-thread', * prompt: `Summarise:\n${thread}`, * store: ctx.store, * offload: true, * }) * ``` * * On replay the journal answers and the model is not called again. */ export declare const aiStep: (options: AiStepOptions) => Effect.Effect; export declare interface AiStepBaseOptions { /** Step name, as it appears in the journal and the dashboard. It also keys * the offloaded call's row, so it has to be stable across a replay — which * it is, being a literal in the body. */ readonly name: string; /** * The prompt: either a bare string, or a `definePrompt(...).render(vars)` * result. * * The rendered form is what buys provenance — the step row, the spend ledger * row and `_voltro_prompts` all end up carrying the same `promptId` + content * digest, so "which prompt version produced this run" is a lookup rather than * an archaeology exercise across deploys. */ readonly prompt: string | RenderedPrompt; /** Overrides a rendered prompt's own system message for this call. */ readonly system?: string; readonly provider?: ProviderConfig; readonly fallbacks?: ProviderFallbacks; readonly maxTokens?: number; readonly providerOptions?: AiProviderOptions; /** * Tools the model may call inside this durable step — the whole LLM↔tool * loop runs as ONE step: one journal entry, one retry policy, one usage row * summing every round-trip. Before this, a durable tool loop had to leave * `aiStep` for the inline `generateObjectWithTools`, and lost the journal and * the per-step spend recording on the way out. */ readonly tools?: Record; /** Max LLM↔tool round-trips before the model must answer. Default 8. */ readonly maxSteps?: number; /** Request context threaded into the tool executors. */ readonly toolContext?: ToolContext; readonly retry?: StepRetryPolicy; readonly recordPrompt?: PromptRecording; /** * The app's store. * * Used for the spend ledger, and REQUIRED for `offload` — that is where the * queue lives. Omitting it on an inline call records no usage, which is a * legitimate choice for a throwaway call and is reported as such rather than * silently assumed. */ readonly store?: DataStore; /** Carried onto the usage row so spend can be split per tenant. */ readonly tenantId?: string | null; /** * Suspend the run while the model thinks, instead of holding a worker. * * The run parks on a durable deferred and a dispatcher performs the call. * Costs one suspend/resume round trip; buys back a worker for the length of * the call. Requires `store`. */ readonly offload?: boolean; /** End-to-end ceiling on an offloaded call. Default one hour. */ readonly offloadTimeoutMs?: number; /** * Hold this call to a per-tenant USD ceiling, checked BEFORE anything is * spent. Requires `store` (the counter and the hold both live in tables). * * See {@link AiStepBudget} — `onExceeded: 'suspend'` is the mode that stops * spending without destroying the run. */ readonly budget?: AiStepBudget; } /** * A per-step USD ceiling, and what happens at it. * * `onExceeded` is the whole point of this option existing. The framework had * two behaviours and neither is a budget CONTROL: * * - `'fail'` is `requireAiBudget`'s: the call fails with the typed * `AiBudgetExceeded` and the whole durable run dies with it, nine steps of * work included. Correct for a hard cap you genuinely want enforced by * destruction, and the reason budgets get set high. * - observation-only (`defineCostBudget`) never stops anything at all. * * `'suspend'` is the third: the run PARKS on a durable hold, frees its worker, * and resumes when the window rolls over or an operator lifts it — then * re-checks and continues from where it stopped. Nothing is spent while held, * and nothing is lost. */ export declare interface AiStepBudget { /** Stable budget name — what a hold row records and an operator lifts. * Default `'ai-usd'`. */ readonly name?: string; /** The per-tenant ceiling in USD. */ readonly limitUsd: number; /** Rolling-window start; folds into the reservation counter's bucket key, so * a new window reserves against a fresh counter. */ readonly since?: Date; /** * What THIS call is expected to cost, RESERVED atomically before the provider * is contacted. * * Omitting it makes the gate a read: the ceiling still holds against spend * that WAS reserved, but N concurrent runs can each observe headroom and then * each spend. Pass an estimate for a real cap — that is the difference between * a ceiling and a speed bump, and it is the same argument * `requireAiBudget`'s CAS-reservation makes against a check-then-act SUM. */ readonly estimateUsd?: number; /** Default `'fail'` — the existing behaviour stays the default, because * suspending is a change in what a run DOES and should be asked for. */ readonly onExceeded?: 'fail' | 'suspend'; /** Ceiling on how long a hold may park, across every re-park. Default 7 days. */ readonly holdTimeoutMs?: number; /** How often a held run wakes to re-read the counter. Default 15 minutes — * a tumbling window rolls over on a clock nothing notifies us about. */ readonly recheckEveryMs?: number; } export declare interface AiStepOptions extends AiStepBaseOptions { } /** What an `aiStep` returns. The usage tally is part of the RESULT, not only of * the ledger — a workflow that wants to branch on token count should not have * to query a table to see what it just spent. */ export declare interface AiStepResult { readonly text: string; readonly inputTokens: number | null; readonly outputTokens: number | null; readonly totalTokens: number | null; } declare type AnthropicModel = 'claude-opus-4-8' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-20251001' | (string & {}); /** A heterogeneous tool — the shape `buildSdkTools` / `runAssistant` consume. * `defineTool` keeps full input typing on its `execute`/`handler` body, then * erases to this shape so a `Record` accepts tools of * differing input/output types (Effect's `Schema` encoded type is invariant, * so a heterogeneous collection can't preserve per-tool generics). */ declare interface AnyTool { readonly name: string; readonly description: string; readonly input: Schema.Schema.Any; /** See `ToolDef.inputJsonSchema` — a foreign parameter schema shown to the * model in place of a rendered `input`. */ readonly inputJsonSchema?: Record; readonly output?: Schema.Schema.Any; readonly execute?: (input: never) => Effect.Effect; readonly handler?: ToolHandler; readonly timeoutMs?: number; } /** The name a hold is scoped to when the caller does not choose one. Operators * lift by this string, so it has to be stable and sayable. */ export declare const DEFAULT_AI_BUDGET_NAME = "ai-usd"; /** Default ceiling on an offloaded call, end to end — one hour. Generous * because the wait itself is free (no worker is held), and a run that gives up * on a slow provider at ten minutes is a run somebody has to re-drive by hand. */ export declare const DEFAULT_OFFLOAD_TIMEOUT_MS: number; /** Creator namespaces the Vercel AI Gateway routes to. Open union — the named * arms are autocomplete, any other creator string still works. */ declare type GatewayCreator = 'openai' | 'anthropic' | 'google' | (string & {}); /** The gateway addresses models as `creator/model` ids. The template-literal * type enforces that shape at COMPILE time — what `providerFromEnv` otherwise * only catches as a runtime throw (`{ name: 'gateway', model: 'gpt-5.5' }` is * now a type error, not a boot crash). */ declare type GatewayModel = `${GatewayCreator}/${string}`; /** * A media generation as a durable step — always offloaded, because a media * job is an external job: minutes long, paid on submission, and owned by * whichever process submitted it. * * The queue row is the job's identity, allocated before the first paid call. * A replay finds the row; a dispatcher that dies mid-job leaves a lease that * expires — and the reclaiming dispatcher does NOT submit again. With a * provider registered through `registerMediaTaskProvider` the row also * carries the provider's task handle, so the reclaim polls the same task and * the run receives its result. A built-in generator has no lookup — and a * task job whose handle never reached the row cannot be found either — so * those are reported to the run as `unknown` (`AiOffloadError.outcome`) * rather than paid for twice. Artifacts are persisted through the storage * plugin's `mediaPersist` capability under `##`, and * the run receives URLs and ref ids. */ export declare const mediaStep: (options: MediaStepOptions) => Effect.Effect; export declare interface MediaStepOptions extends Omit { readonly modality: 'image' | 'video' | 'speech'; /** A built-in provider, or the name of one registered with * `registerMediaTaskProvider` — the dispatcher routes by the name. */ readonly provider?: ProviderConfig | RegisteredMediaTaskConfig; /** Provider parameters — `n`, `size`, `aspectRatio`, `seed` for an image; * `durationSeconds`, `resolution`, `fps` for a video; `voice`, `format` for * speech. Passed through to the generator, journaled with the job. */ readonly params?: Record; /** URL inputs — reference images, a mask, a starting frame. URLs only: the * job row travels through the queue and a resume payload, never bytes. */ readonly inputs?: Record; /** Required: a media job is always an external job with a durable identity. */ readonly store: DataStore; /** Who the stored artifacts belong to, how they are served, and the tags they * are found by — handed to the storage plugin's `mediaPersist` as-is. */ readonly persist?: PluginMediaPersistMetadata; } export declare interface MediaStepResult { readonly artifacts: ReadonlyArray<{ readonly url: string; readonly refId: string | null; readonly mediaType: string; }>; readonly usage: unknown; readonly providerMetadata: unknown; } /** A scripted inline file the mock model emits on a turn (usually an image). * `data` is base64 (a short ascii string works for assertions). */ declare interface MockFile { readonly mediaType: string; readonly data: string; } /** A consumable sequence of model turns, plus a recorder for asserting on * what the model was asked. Each `doGenerate`/`doStream` advances the * cursor by one turn; running past the end repeats the last turn's text * (or empties) so a loop that over-steps doesn't hang. */ declare class MockScript { private readonly turns; private cursor; readonly calls: Array<{ readonly prompt: unknown; readonly tools: ReadonlyArray; }>; constructor(turns: ReadonlyArray); record(prompt: unknown, tools: ReadonlyArray): void; next(): MockTurn; reset(): void; } /** A scripted citation the mock model emits on a turn (RAG grounding). */ declare interface MockSource { readonly sourceType?: 'url' | 'document'; readonly id?: string; readonly url?: string; readonly title?: string; readonly mediaType?: string; readonly filename?: string; } /** A scripted tool call the mock model emits on a turn. */ declare interface MockToolCall { readonly name: string; readonly input: unknown; } /** One model turn. A turn can carry text (streamed word-by-word), scripted * tool calls (which trigger the SDK's tool loop), cited sources, inline files, * or an error. A turn with tool calls + no text drives one LLM↔tool * round-trip. */ declare interface MockTurn { readonly text?: string; /** Reasoning ("thinking") the mock streams before the answer text. */ readonly reasoning?: string; readonly toolCalls?: ReadonlyArray; readonly sources?: ReadonlyArray; readonly files?: ReadonlyArray; readonly error?: { readonly message: string; }; } declare type OpenAIModel = 'gpt-5.5' | 'gpt-4o' | 'gpt-4o-mini' | (string & {}); declare interface PromptDefinition { /** * Stable identity across revisions. Dotted, like a descriptor tag * (`support.triage`) — the same vocabulary the rest of the framework uses for * "the name of a thing an app owns". */ readonly id: string; /** * The template, with `{{name}}` placeholders. A STRING, not a function, and * that is the design: a function has no stable content to hash, so a * function-built prompt could be versioned only by hashing its output — which * would make every distinct customer message a new "version". Compose in the * variables instead. */ readonly template: string; /** Optional system message. Part of the digest — changing it IS a new * version, because it changes the model's behaviour as surely as the * template does. */ readonly system?: string; /** Optional human label for this revision (`'shorter-system'`). Metadata: * deliberately NOT part of the digest, so relabelling does not fork a * version. */ readonly label?: string; } /** The digest form: a prefix, not the whole hash. This is a correlation handle, * and a full sha256 in a dashboard cell is a wall of hex nobody reads. * * Lives here rather than in `inferStep.ts` (where it started) because it is now * the identity of a versioned artefact, and `inferStep` imports * `@voltro/workflow` — a prompt registry has no business needing the workflow * engine to compute a hash. `inferStep` re-exports it. */ export declare const promptDigest: (prompt: string) => string; /** * What gets written to `_voltro_workflow_run_steps.input` for a prompt. * * Exported because it is the security-relevant half of this module and it must * be assertable on its own: "the raw prompt is not in the recorded input" is a * property about a string, and testing it through a live model call would test * the mock instead. * * `stamp` is the PROVENANCE half and is recorded in EVERY mode, `'none'` * included. That is deliberate and it is the point of the split: the reason to * record nothing about a prompt is that its TEXT is sensitive, and a prompt id * plus a content digest is neither the text nor derivable from it. Dropping the * provenance along with the content would mean the most privacy-conscious * setting is also the one where you cannot tell which prompt version ran. */ export declare const promptRecordFor: (prompt: string, system: string | undefined, mode: PromptRecording, stamp?: PromptStamp) => Record; /** * How the prompt is written to the step row the dashboard renders. * * `'digest'` (default) — a sha256 prefix plus the character count. Enough to * tell two runs apart, to spot a prompt that ballooned, and to correlate a * cached result; not enough to leak the content of one. * * `'full'` — the prompt verbatim. Right for a prompt built from constants and * a row id; wrong for one built from a customer's message, and typing it out is * the point. * * `'none'` — record nothing about the prompt. */ export declare type PromptRecording = 'digest' | 'full' | 'none'; /** A prompt's identity, as it is stamped onto a step row and a usage row. */ declare interface PromptStamp { /** The author-chosen stable id, e.g. `support.triage`. */ readonly promptId: string; /** Content digest of the template (+ system). CHANGES when the template does * — that is the version. */ readonly digest: string; /** The monotonic revision `recordPromptVersion` assigned, once it has been * registered. Absent before first registration; the digest is the identity * either way. */ readonly revision?: number; /** Optional human label the author gave this revision. */ readonly label?: string; } /** * A resolved provider choice — a DISCRIMINATED UNION on `name`. Each provider * only carries the fields that apply to it: the mock-only `mockText` / `script` * cannot appear on a real provider, and a real provider always carries a * `model` typed to that provider's ids. `apiKey` / `baseURL` switch * `resolveModel` to a per-config provider instance (per-agent key / BYOK); * omit them to fall back to the provider's standard env var (OPENAI_API_KEY / * ANTHROPIC_API_KEY / AI_GATEWAY_API_KEY). */ declare type ProviderConfig = { readonly name: 'mock'; /** Optional for mock — defaults to `'mock'`. */ readonly model?: string; /** The exact text the mock returns (instead of echoing the prompt). * Lets tests/demos drive `generateObject` with canned JSON. */ readonly mockText?: string; /** A multi-turn script. Takes precedence over `mockText` — each * `doGenerate`/`doStream` consumes the next turn, so the SDK's tool * loop runs deterministically. */ readonly script?: MockScript; /** Provider metadata the mock attaches to its `doGenerate` result — lets a * test exercise the gateway-cost path (`gatewayCostUsd`) deterministically * by faking `{ gateway: { cost } }`. Also surfaced by the mock speech + * video media models. */ readonly providerMetadata?: AiProviderOptions; /** Canned output for the media models (`resolveImageModel` / * `resolveSpeechModel` / `resolveVideoModel`). Every field is optional and * falls back to a deterministic default, so `useMockAi` yields valid media * without configuration; set a field to assert on exact bytes / URL. */ readonly mockMedia?: { /** base64 image payload `generateImage` decodes to bytes (default: a * 1×1 PNG header, so media-type detection resolves to `image/png`). */ readonly imageBase64?: string; /** base64 audio payload `generateSpeech` decodes to bytes. */ readonly audioBase64?: string; /** URL the mock video model yields (the default branch — most video * providers return a URL rather than inline bytes). */ readonly videoUrl?: string; /** base64 the mock video model yields INSTEAD of a URL — exercises the * bytes branch of `generateVideo`. Takes precedence over `videoUrl`. */ readonly videoBase64?: string; /** The text the mock transcription model returns. Lets a test assert on * an exact transcript without a provider. */ readonly transcript?: string; }; } | { readonly name: 'anthropic'; readonly model: AnthropicModel; readonly apiKey?: string; readonly baseURL?: string; } | { readonly name: 'openai'; readonly model: OpenAIModel; readonly apiKey?: string; readonly baseURL?: string; } | { readonly name: 'gateway'; /** A `creator/model` id, e.g. 'openai/gpt-5.5'. */ readonly model: GatewayModel; readonly apiKey?: string; readonly baseURL?: string; }; /** * An ordered provider/model fallback chain. When a call FAILS on the primary * provider (`provider ?? providerFromEnv()`) — after that provider's OWN * SDK-level retries — the call re-runs against `fallbacks[0]`, then * `fallbacks[1]`, … until one succeeds. Exhausting every option surfaces the * LAST provider's typed error (an `AiError({ reason: 'generation' })`). Only a * `generation` failure (the provider call itself) falls through — a `decode` * failure (the model answered but the output didn't satisfy the schema) is a * prompt/schema problem another provider won't fix, so it surfaces immediately. * An empty / absent chain leaves behavior unchanged. */ declare type ProviderFallbacks = ReadonlyArray; /** * The provider config `mediaStep` accepts for a registered task provider — * named, not enumerated, exactly like `RegisteredSpeechConfig`. */ declare interface RegisteredMediaTaskConfig { readonly name: string; readonly model?: string; } /** What `.render(vars)` produces — a stamp plus the text to send. */ declare interface RenderedPrompt extends PromptStamp { readonly prompt: string; readonly system?: string; /** The artefact this was rendered from. Carried along so a deployment that * receives only the rendered value (`aiStep`) can REGISTER the version * without the app having to remember a separate boot-time call. It is the * template — code, not data — so carrying it costs nothing a repository * checkout does not already cost. */ readonly definition: PromptDefinition; } /** * Split a step's `prompt` option into the text to send, the system message, and * the provenance stamp (if it came from a `definePrompt`). * * `system` on the step wins over the prompt definition's, so a caller can * override for one call without forking a prompt version — the digest still * describes the ARTEFACT, and the override is visible in the step row. */ export declare const resolveStepPrompt: (options: Pick) => { readonly text: string; readonly system: string | undefined; readonly rendered: RenderedPrompt | undefined; }; /** The slice of the request context a `*.tool.tsx` handler receives. Kept * structurally typed here (rather than importing `AppContext` from * `@voltro/runtime`) so `@voltro/ai` carries no runtime dependency — the * CLI discovery path passes the real `AppContext`, which is a superset. * * `runtime` is the caller's ambient Effect `Runtime`. When present, tool * bodies are run through it (`Runtime.runPromise(runtime)(eff)`) instead of * bare `Effect.runPromise`, so a body may `yield*` any service the caller's * runtime carries — `JiraService`, the data store layer, `HttpClient`, … — * rather than being limited to `R = never`. The agent loop * (`streamText`/`runAssistant`) captures `yield* Effect.runtime()` and threads * it here; without a runtime, behaviour is unchanged. */ declare interface ToolContext { readonly store: unknown; readonly request?: { readonly subject?: unknown; }; readonly runtime?: Runtime.Runtime; /** SDK tool-call identity for this execution, not a durable application idempotency key. */ readonly invocationId?: string; /** Cancellation from either the caller's turn or SDK invocation; Promise handlers must pass it to their I/O. */ readonly abortSignal?: AbortSignal; } /** A context-receiving tool body — the `*.tool.tsx` default-export form. * May return an Effect (with service deps the caller's runtime provides) or * a Promise; `buildSdkTools` normalises both. */ declare type ToolHandler = (input: A, ctx: ToolContext) => Effect.Effect | Promise; /** * Transcription as a durable step — with `offload`, which is the point. * * A ninety-minute recording is not a request. Without offloading, a worker holds * a connection for the whole call; `aiStep` and `aiObjectStep` have had * `offload: true` all along and transcription — the modality that runs LONGEST — * did not. The queue row carries the SOURCE, never the bytes: putting the audio * in the row would reintroduce exactly the cost offloading exists to remove. */ export declare const transcribeStep: (options: TranscribeStepOptions) => Effect.Effect<{ readonly text: string; readonly language?: string; readonly durationSec?: number; }, unknown>; export declare interface TranscribeStepOptions extends Omit { readonly source: TranscriptionSource; readonly language?: string; readonly timestamps?: TranscriptionTimestamps; readonly diarize?: boolean; readonly translateToEnglish?: boolean; /** Resolve a `storageRef`. Required only for a ref source — and for an * OFFLOADED one it must be wired on the dispatcher too, since that is the * process that will do the read. */ readonly resolveRef?: (ref: string) => Promise<{ bytes: Uint8Array; mediaType: string; }>; } /** * Where the audio is. * * `storageRef` is the one to reach for: the file is already in the app's * storage, and resolving it there keeps tenancy, signing and lifetime with the * component that owns them. The resolver is injected rather than imported so * this module stays free of a storage dependency — `@voltro/ai` must not learn * about buckets to transcribe a file. */ declare type TranscriptionSource = /** Resolved by the app's own storage — the case to reach for. */ { readonly storageRef: string; } /** Fetched by this function. Convenient, but the bytes do pass through this * process: the SDK takes bytes or base64 and never a location. */ | { readonly url: string; } /** Already in hand. The one that costs memory by construction. */ | { readonly bytes: Uint8Array; readonly mediaType: string; }; /** What a `transcribeStep` journals. Structural, because a durable step's * success schema is what a replay decodes through — see `AiStepText`. */ export declare const TranscriptionStepResult: Schema.Struct<{ text: typeof Schema.String; language: Schema.optional; durationSec: Schema.optional; }>; /** How much timestamp detail to ask for. Providers charge for it. */ declare type TranscriptionTimestamps = 'none' | 'segment' | 'word'; export { }