Functions

assistant-message

fn (text: Str?, tool-calls: Vec<::ai::tool/ToolCall>?): Message

Construct a Message representing an assistant turn. When tool-calls is non-null the message records a tool-use turn that must be paired with one or more tool-result-message entries on the next user turn.

builtin-providers

fn (): Vec

Built-in provider registry. Populated lazily so this namespace doesn't pin a hard dependency on every provider package — entries only carry variant, detect, and display-name. Concrete chat-fn references live in user-facing setup code or in the with-provider-fns helper below.

check-budget

fn (opts: ChatOptions, messages: Vec, tools: Vec<::ai::tool/Tool>?): Null

Internal: enforce opts.max-context-tokens / opts.warn-context-pct against messages + system + tools.

  • If neither limit is set, returns silently.
  • If count-tokens-fn is set, uses it for messages; otherwise falls back to count-tokens-heuristic.
  • Logs a warning via tap when the projected total crosses the warn-context-pct threshold of the limit.
  • Fails (fail) when the projected total exceeds the limit outright. The failure carries a structured breakdown.

count-message-chars

fn (m: Message): Int

Internal: serialize a single Message to a char-count for the shared heuristic.

count-tokens-heuristic

fn (messages: Vec, model: Str): Int

Provider-agnostic char/4 token estimator. Use as a fallback for ChatOptions.count-tokens-fn when a provider lacks a precise counter (e.g. OpenAI without tiktoken). Tracks BPE tokenizers to within ~10% on English prose; code/JSON/non-Latin will skew higher.

Contract matches the count-tokens-fn slot:

(messages: Vec<Message>, model: Str): Int

detect-provider

fn (model: Str): Provider

Map a model name to its Provider variant. Walks builtin-providers() first, then falls back to the legacy MODEL_PREFIXES table for backward compatibility. Returns Err for unrecognized models. Use detect-provider-from when you have a custom registry.

Example

detect-provider("claude-sonnet-4-5")  // Provider.Anthropic
detect-provider("gpt-4o")             // Provider.OpenAi

detect-provider-from

fn (registry: Vec, model: Str): ProviderRegistration?

Find the registry entry whose detect returns true for model. Walks registry from the end first so user-supplied overrides win over built-ins. Returns null when nothing matches.

dispatch-chat

fn (opts: ChatOptions, messages: Any, tools: Any): Any

Dispatch a chat call using opts. Resolution order:

  1. If opts.chat-fn is set, call it directly.
  2. Otherwise resolve a ProviderRegistration via opts.providers (or built-ins) and call reg.chat-fn if populated.
  3. Otherwise fail with a clear message.

Use this helper in any code that wants to honor ChatOptions.providers without bypassing chat-fn's explicit-override semantics.

dispatch-chat-stream

fn (opts: ChatOptions, messages: Any, tools: Any, on-delta: Fn): Any

Streaming counterpart to dispatch-chat. Honors explicit opts.chat-stream-fn first, then opts.providers[*].chat-stream-fn.

emit-step

fn (opts: ChatOptions, iteration: Int, reply: ChatReply, tool-results: Vec?): Null

Internal: publish a per-turn step record to the current run's stream when opts.emit-steps is on. Errors (e.g. no stream context) are swallowed so callers can opt in unconditionally.

emit-stream-delta

fn (opts: ChatOptions, delta: ReplyDelta): Null

Internal: forward a ReplyDelta to the current run's stream when opts.emit-stream-deltas is on. Errors (e.g. no stream context) are swallowed.

estimate-system-tokens

fn (system: Str?): Int

Internal: char/4 heuristic for the system prompt.

estimate-tools-tokens

fn (tools: Vec<::ai::tool/Tool>?): Int

Internal: char-based heuristic for the JSON-serialized weight of a tool list. Adds ~10 tokens of overhead per tool to account for schema formatting in the wire request.

flatten-parts

fn (parts: Vec): Str

Collapse a Vec<MessagePart> into a single string for adapters that don't yet support multi-part input. Text parts are concatenated with \\n\\n separators; image/audio/file parts become bracketed placeholders so the model knows something was attached even when the bytes can't be sent.

Returns the empty string for empty input.

format-budget-breakdown

fn (parts: Map, total: Int, limit: Int): Str

Internal: human-readable breakdown of a token budget overrun.

image-part

fn (url: Str): MessagePart
fn (data: Str, mime: Str): MessagePart

Convenience: build an image MessagePart. Either data (base64) or url should be set; mime should describe the encoding when data is set.

make-usage

fn (raw: Map?, provider-name: Str?, model: Str?): ChatUsage

Build a ChatUsage from a provider-native usage map. Accepts both Anthropic-style (input_tokens/output_tokens) and OpenAI-style (prompt_tokens/completion_tokens) keys; missing fields stay null. total-tokens is computed when both input and output are present.

usage ::ai::chat/make-usage({
    prompt_tokens: 120,
    completion_tokens: 84,
    latency_ms: 1280,
})

message-text

fn (msg: Message): Str

Extract the textual content of a Message, regardless of whether it's stored in content or in parts. Adapters that haven't moved to multi-part input use this to keep working with multi-part-aware callers.

noop-on-delta

fn (delta: ReplyDelta): Null

Default on-delta callback used when a streaming caller does not supply one. Discards every event.

ns alias

Alias of ::ai::chat/

Provider-agnostic chat surface used by every ::ai::* consumer.

This module defines:

  • Provider enum and a model-name → provider mapping for routing.
  • Message and ChatReply types that normalize provider responses so downstream code never branches on the underlying SDK.
  • ChatOptions carrying the chat function reference, model name, optional system prompt, and (for tool-using flows) tools plus a max-iterations cap.
  • run-loop, the canonical agent loop that calls chat-fn, dispatches tool_use blocks via ::ai::tool/dispatch, and iterates until the model finishes or the iteration cap is hit.

Provider packages (e.g. ::anthropic::messages, ::openai::chat) each expose a chat-with-tools function matching the new chat-fn contract:

chat-fn(model: Str, messages: Vec<Message>, system: Str?, tools: Vec<Tool>?): ChatReply

The legacy (model, prompt, system) -> Str shape used by ::ai::rag is still supported as-is — only run-loop callers must adopt the new shape.

prefix-detector

fn (prefix: Str): Fn

Build a detect function that matches when the model starts with prefix (case-insensitive).

provider-name

fn (provider: Provider): Str

Human-readable name for a Provider. Falls back to the variant's short name for any provider enrolled by a third-party package via Source -> Provider.Variant arrows. The _ default arm is required because Provider is an open enum.

render-part

fn (p: MessagePart): Str

Internal: render a single MessagePart for flatten-parts.

resolve-registration

fn (opts: ChatOptions): Any

Pick the ProviderRegistration whose detect matches opts.model, using opts.providers first if set, otherwise the built-in registry.

Returns the whole registration value rather than its chat-fn. This is intentional — Hot reliably round-trips struct values across function boundaries, but extracting a typed fn field and returning it as a value does not preserve the fn ref. Call reg.chat-fn(...) directly on the returned registration to dispatch.

REGISTRY ::ai::chat/with-providers([::ollama-chat/provider()])
opts ::ai::chat/ChatOptions({model: "ollama/llama3", providers: REGISTRY})
reg ::ai::chat/resolve-registration(opts)
cond {
    is-null(reg) => { fail(`No provider for ${opts.model}`) }
    => { reg.chat-fn(opts.model, messages, opts.system, tools) }
}

run-loop

fn (opts: ChatOptions, user-msg: Str): Str

Drive a chat conversation through any number of tool-use turns, stopping when the model says end_turn or when max-iterations is reached.

opts.chat-fn must match the tools-aware contract:

(model: Str, messages: Vec<Message>, system: Str?, tools: Vec<Tool>?) -> ChatReply

On each iteration:

  1. Call chat-fn with the running message list.
  2. If the reply has no tool calls, return its text.
  3. Otherwise, dispatch every tool call via ::ai::tool/dispatch, append the assistant turn and one tool-result turn per call, and loop.

Errors from individual tool calls are surfaced back to the model as tool-result-message entries with the failure text — the model is allowed to recover. Hitting max-iterations raises a fail.

Example

add fn (x: Int, y: Int): Int { add(x, y) }

opts ::ai::chat/ChatOptions({
    chat-fn: ::anthropic::messages/chat-with-tools,
    model: "claude-sonnet-4-5",
    tools: [::ai::tool/from-fn(add)],
    max-iterations: 5
})

answer ::ai::chat/run-loop(opts, "What is 17 + 25?")
// answer = "17 + 25 is 42."

run-loop-messages

fn (opts: ChatOptions, messages: Vec): Str

Variant of run-loop that takes an explicit Vec<Message> as starting history. Useful for resuming an existing conversation.

Example

history [
    ::ai::chat/user-message("Hi"),
    ::ai::chat/assistant-message("Hello!", null),
    ::ai::chat/user-message("How are you?")
]
::ai::chat/run-loop-messages(opts, history)

run-loop-step

fn (opts: ChatOptions, messages: Vec, tools: Vec<::ai::tool/Tool>, remaining: Int): Str

Internal: one iteration of run-loop. Tail-recursive.

run-loop-stream

fn (opts: ChatOptions, user-msg: Str): Str
fn (opts: ChatOptions, user-msg: Str, on-delta: Fn?): Str

Streaming counterpart to run-loop. Drives a tools-aware conversation through any number of turns, invoking on-delta(delta: ReplyDelta) for every event the underlying chat-stream-fn emits across all turns.

opts.chat-stream-fn must match the streaming contract:

(model: Str, messages: Vec<Message>, system: Str?,
 tools: Vec<Tool>?, on-delta: Fn) -> ChatReply

On each iteration the function:

  1. Calls chat-stream-fn with the running message list and a merged on-delta that also forwards to ::hot::stream/data when opts.emit-stream-deltas is on.
  2. Collects the final ChatReply. If no tool calls, returns the accumulated text.
  3. Otherwise dispatches every tool call via ::ai::tool/dispatch, appends the assistant turn + tool-result turns, and loops.

Errors and max-iterations behavior match run-loop.

Example

on-token (delta: ::ai::chat/ReplyDelta) {
    match delta {
        ::ai::chat/ReplyDelta.TextDelta => { print(delta.text) }
        => { null }
    }
}

opts ::ai::chat/ChatOptions({
    chat-stream-fn: ::anthropic::chat-tools/chat-with-tools-stream,
    model: "claude-sonnet-4-5",
    tools: [::ai::tool/from-fn(add)]
})

answer ::ai::chat/run-loop-stream(opts, "What is 17 + 25?", on-token)

run-loop-stream-messages

fn (opts: ChatOptions, messages: Vec, on-delta: Fn?): Str

Variant of run-loop-stream that takes an explicit Vec<Message> as starting history. Useful for resuming an existing conversation while still streaming the next response.

run-loop-stream-step

fn (opts: ChatOptions, messages: Vec, tools: Vec<::ai::tool/Tool>, remaining: Int, on-delta: Fn): Str

Internal: one iteration of run-loop-stream. Tail-recursive.

text-part

fn (text: Str): MessagePart

Convenience: build a MessagePart({kind: "text", text: …}).

tool-result-message

fn (result: ::ai::tool/ToolResult): Message

Construct a Message carrying a ::ai::tool/dispatch result back to the model. Echoes result.id as tool-call-id so providers can correlate it with the originating ToolCall.

user-message

fn (text: Str): Message

Construct a Message with role: "user" and the given text content.

with-providers

fn (extras: Vec): Vec

Compose a registry from the built-in entries plus extras. Later entries take precedence over earlier ones when their detect matches the same model — this lets a third-party adapter override a built-in (e.g. routing all gpt-* calls through a self-hosted proxy).

REGISTRY ::ai::chat/with-providers([
    ::ollama-chat/provider(),
    ::mistral-chat/provider(),
])

wrap-on-delta

fn (opts: ChatOptions, user-on-delta: Fn?): Fn

Internal: build the effective per-turn on-delta callback — combines an optional caller-supplied user-on-delta with the stream-emit forwarder controlled by opts.emit-stream-deltas.

Types

ChatOptions

ChatOptions type {
    chat-fn: Fn?,
    chat-stream-fn: Fn?,
    model: Str,
    system: Str?,
    tools: Vec<::ai::tool/Tool>?,
    skills: Vec?,
    skill-resolver: ::ai::skill/SkillResolver?,
    skill-context: Map?,
    providers: Vec?,
    max-iterations: Int?,
    max-context-tokens: Int?,
    max-output-tokens: Int?,
    warn-context-pct: Dec?,
    emit-steps: Bool?,
    step-data-type: Str?,
    emit-stream-deltas: Bool?,
    delta-data-type: Str?,
    count-tokens-fn: Fn?,
    model-context-window: Int?
}

Options bundle for chat-consuming functions.

Required

  • chat-fn — function reference used to call the underlying provider. Two contracts are recognized:

    • Legacy: (model: Str, prompt: Str, system: Str?) -> Str — used by ::ai::rag and other single-shot helpers.
    • Tools-aware: (model: Str, messages: Vec<Message>, system: Str?, tools: Vec<Tool>?) -> ChatReply — required by run-loop.
  • model — model identifier passed through to the provider.

  • chat-stream-fn — streaming counterpart used by run-loop-stream. Contract:

    (model: Str, messages: Vec<Message>, system: Str?,
     tools: Vec<Tool>?, on-delta: Fn) -> ChatReply
    

    The function invokes on-delta(delta: ReplyDelta) for each event in turn order and returns the same ChatReply shape as the non-streaming version.

Optional

  • system — system prompt prepended to every call.
  • tools — tools the model is allowed to call from run-loop.
  • skills — vector of skill-meta'd functions to advertise to the model. run-loop wraps these in ::ai::skill/in-memory-resolver and exposes the list_skills/read_skill/apply_skill built-in tools automatically. Ignored when skill-resolver is set.
  • skill-resolver — explicit ::ai::skill/SkillResolver. Takes precedence over skills and lets callers plug in store-backed, embedding-ranked, or otherwise dynamic skill discovery while reusing the run-loop's index + built-in-tools machinery.
  • skill-context — per-turn map merged into every apply_skill invocation's ctx argument before the skill's body-fn runs. The harness's keys override anything the model passes for the same key — useful for threading inbound attachments, sender identity, transport, or session info into skill resolution without trusting the model to populate them. Ignored by static body skills (those are returned verbatim).
  • max-iterations — hard cap on run-loop turns (default 10).
  • max-context-tokens, max-output-tokens, warn-context-pct — reserved for Phase 1.5 token-budget enforcement; ignored today.
  • emit-steps — when true, run-loop publishes a per-turn observability record to the current run's stream via ::hot::stream/data. Defaults to false. Errors from missing stream context are swallowed so opting in is safe in any execution environment.
  • step-data-type — stream data-type label used when emit-steps is on. Defaults to "ai:chat:step".
  • emit-stream-deltas — when true, run-loop-stream mirrors every ReplyDelta to the current run's stream via ::hot::stream/data (in addition to invoking any caller on-delta). Defaults to false. Errors from missing stream context are swallowed.
  • delta-data-type — stream data-type label used when emit-stream-deltas is on. Defaults to "ai:chat:delta".
  • count-tokens-fn — provider's (messages, model) -> Int counter used by pre-call budget checks. When unset, falls back to count-tokens-heuristic.
  • model-context-window — explicit context-window size in tokens. When unset, the budget check uses max-context-tokens directly without computing a percentage.

Example

::tool ::ai::tool
::anth ::anthropic::messages

weather-tool ::tool/from-fn(get-weather)

opts ChatOptions({
    chat-fn: ::anth/chat-with-tools,
    model: "claude-sonnet-4-5",
    system: "You are a concise assistant.",
    tools: [weather-tool],
    max-iterations: 5
})

ChatReply

ChatReply type {
    text: Str?,
    tool-calls: Vec<::ai::tool/ToolCall>?,
    stop-reason: Str,
    usage: Any?,
    raw: Any?
}

Normalized provider response. Returned by the new chat-fn contract and consumed by run-loop.

Fields

  • text — the assistant's text output for this turn (may be empty when the turn is purely tool-use).
  • tool-calls — parsed ToolCall records when the model requested tool use.
  • stop-reason"end_turn", "tool_use", "max_tokens", "stop_sequence", or any other provider-specific reason.
  • usage — token accounting and cost. Adapters that have moved to structured usage populate this with a ChatUsage; legacy adapters still populate it with a raw Map and callers use make-usage to coerce. Phase 1.5 will normalize on ChatUsage.
  • raw — the raw provider response for adapter use; opaque to run-loop callers.

ChatUsage

ChatUsage type {
    input-tokens: Int?,
    output-tokens: Int?,
    total-tokens: Int?,
    cost-usd: Dec?,
    latency-ms: Int?,
    provider: Str?,
    model: Str?
}

Provider-agnostic usage and cost shape. Returned alongside ChatReply.usage by adapters that have populated it. Construct via make-usage(map) to coerce a provider's native usage map (with fields like prompt_tokens, completion_tokens) into the normalized shape.

Fields

  • input-tokens — tokens consumed by the prompt + tool definitions.
  • output-tokens — tokens generated by the model.
  • total-tokens — convenience sum (input + output).
  • cost-usd — provider-reported cost in USD when available.
  • latency-ms — wall-clock time the call took, when measured by the adapter.
  • providerdisplay-name of the provider that handled the call.
  • model — concrete model name (e.g. "claude-sonnet-4-5").

Message

Message type {
    role: Str,
    content: Any,
    parts: Vec?,
    tool-calls: Vec<::ai::tool/ToolCall>?,
    tool-call-id: Str?
}

One turn of a chat conversation in normalized form. Provider adapters translate this into their native message shape.

Fields

  • role"user", "assistant", "system", or "tool".
  • content — usually a Str (plain text). For role: "tool" it is the result returned by ::ai::tool/dispatch. For role: "assistant" turns that called tools it may be empty. When parts is set, adapters that support multi-part input should prefer parts and treat content as a flattened fallback.
  • parts — optional multi-part body. Set this when the message mixes text with images, audio, or files. Adapters that don't support multi-part input call flatten-parts(parts) to get a string equivalent.
  • tool-calls — present on role: "assistant" turns that requested tool use; carries the parsed ToolCall records.
  • tool-call-id — present on role: "tool" results, echoing the id of the originating ToolCall.

Example

user-msg Message({role: "user", content: "What's the weather in Paris?"})

multimodal Message({
    role: "user",
    content: "What's in this image?",
    parts: [
        text-part("What's in this image?"),
        image-part(image-bytes, "image/png"),
    ],
})

assistant-call Message({
    role: "assistant",
    content: "",
    tool-calls: [::tool/ToolCall({id: "tu_1", name: "get-weather", input: {city: "Paris"}})]
})

tool-result Message({
    role: "tool",
    content: "18C and clear",
    tool-call-id: "tu_1"
})

MessagePart

MessagePart type {
    kind: Str,
    text: Str?,
    mime: Str?,
    data: Str?,
    url: Str?,
    name: Str?,
    meta: Map?
}

One element of a multi-part message. Used by adapters and agent code to pass mixed text + image + audio + file content into a single Message turn without falling back to provider-specific shapes.

Fields

  • kind"text" | "image" | "audio" | "file" | "tool-result".
  • text — UTF-8 string content. Set for kind: "text" and as a caption fallback on other kinds.
  • mime — best-effort media type (e.g. "image/png", "audio/mp3").
  • data — base64-encoded bytes when the part is inlined.
  • url — addressable URL when the part is referenced rather than inlined.
  • name — display label (filename for files, alt text for images).
  • meta — open map for adapter-specific hints (Anthropic cache_control, OpenAI image.detail, etc.).

Adapters that don't yet support multi-part input MUST flatten parts via flatten-parts(parts) before sending — this collapses text parts into a single string and discards everything else with a placeholder note so the model sees something coherent.

Provider

Known AI chat completion providers. Declared enum open so third-party adapters can register their own provider identity via arrow enrollment without forking this enum:

Mistral type { name: Str }
Mistral -> Provider.Mistral

Match expressions on Provider MUST include a _ default arm (open-enum-match-missing-default otherwise).

ProviderRegistration

ProviderRegistration type {
    variant: Provider,
    detect: Any,
    display-name: Str,
    chat-fn: Any,
    chat-stream-fn: Any,
    embed-fn: Any,
    meta: Map?
}

Open-registry entry describing a chat-completion provider. First-party providers (Anthropic, OpenAI, xAI, Gemini) populate the built-in registry; third-party packages register their own via with-providers.

Fields

  • variant — the Provider enum variant (open enum, third parties enroll via arrow: MyProviderTag -> Provider.MyProviderTag).
  • detect(model: Str) -> Bool. Returns true when this entry should handle the given lowercased model name.
  • display-name — human-readable label used in logs and provider-name.
  • chat-fn — tools-aware chat function matching the (model, messages, system, tools) -> ChatReply contract.
  • chat-stream-fn — optional streaming variant.
  • embed-fn — optional embeddings function for RAG.
  • meta — open map for capability flags (vision, audio, tool-choice, etc.).

ReplyDelta

Provider-agnostic streaming event emitted by a chat-stream-fn. Each event is one of:

  • TextDelta — a text fragment (concatenate to rebuild the turn).
  • ToolUseStart — a new tool call has begun (id, name).
  • ToolUseInputDelta — partial JSON of a tool call's input.
  • ToolUseEnd — a tool call has finished streaming.
  • Stop — terminal event with stop-reason and optional usage.

Provider adapters translate native SSE/wire events into this enum so consumers (and run-loop-stream) never branch on the underlying SDK.

Declared enum open so provider adapters can introduce provider-specific delta kinds (e.g., ThinkingDelta, ReasoningDelta) via arrow enrollment without forking this type. Match expressions on ReplyDelta MUST include a _ default arm (open-enum-match-missing-default otherwise) — unknown deltas should be silently ignored or forwarded.

StreamStop

StreamStop type {
    reason: Str,
    usage: Map?
}

Final event of a streaming turn. reason mirrors ChatReply.stop-reason ("end_turn", "tool_use", "max_tokens", …). usage carries the normalized {input-tokens, output-tokens} map when the provider reports it on this terminal event.

TextDelta

TextDelta type {
    text: Str
}

Incremental text fragment from a streaming reply. text is the delta only — concatenate them in order to reconstruct the full assistant text for the turn.

ToolUseEnd

ToolUseEnd type {
    id: Str
}

A tool_use block has finished streaming. The accumulated input JSON is now ready to parse and dispatch.

ToolUseInputDelta

ToolUseInputDelta type {
    id: Str,
    partial-input-json: Str
}

Partial JSON fragment of a tool call's input. Concatenate the partial-input-json strings for a given id, then from-json the result to reconstruct the call's input map.

ToolUseStart

ToolUseStart type {
    id: Str,
    name: Str
}

A new tool_use block has begun in a streaming reply. The model has chosen name for tool-call id; the input arguments stream as ToolUseInputDelta events until a matching ToolUseEnd.