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-fnis set, uses it for messages; otherwise falls back tocount-tokens-heuristic. - Logs a warning via
tapwhen the projected total crosses thewarn-context-pctthreshold 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:
- If
opts.chat-fnis set, call it directly. - Otherwise resolve a
ProviderRegistrationviaopts.providers(or built-ins) and callreg.chat-fnif populated. - Otherwise
failwith 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 of ::ai::chat/
Provider-agnostic chat surface used by every ::ai::* consumer.
This module defines:
Providerenum and a model-name → provider mapping for routing.MessageandChatReplytypes that normalize provider responses so downstream code never branches on the underlying SDK.ChatOptionscarrying the chat function reference, model name, optional system prompt, and (for tool-using flows)toolsplus amax-iterationscap.run-loop, the canonical agent loop that callschat-fn, dispatchestool_useblocks 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:
- Call
chat-fnwith the running message list. - If the reply has no tool calls, return its text.
- 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:
- Calls
chat-stream-fnwith the running message list and a merged on-delta that also forwards to::hot::stream/datawhenopts.emit-stream-deltasis on. - Collects the final
ChatReply. If no tool calls, returns the accumulated text. - 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::ragand other single-shot helpers. - Tools-aware:
(model: Str, messages: Vec<Message>, system: Str?, tools: Vec<Tool>?) -> ChatReply— required byrun-loop.
- Legacy:
model— model identifier passed through to the provider.chat-stream-fn— streaming counterpart used byrun-loop-stream. Contract:(model: Str, messages: Vec<Message>, system: Str?, tools: Vec<Tool>?, on-delta: Fn) -> ChatReplyThe function invokes
on-delta(delta: ReplyDelta)for each event in turn order and returns the sameChatReplyshape as the non-streaming version.
Optional
system— system prompt prepended to every call.tools— tools the model is allowed to call fromrun-loop.skills— vector of skill-meta'd functions to advertise to the model.run-loopwraps these in::ai::skill/in-memory-resolverand exposes thelist_skills/read_skill/apply_skillbuilt-in tools automatically. Ignored whenskill-resolveris set.skill-resolver— explicit::ai::skill/SkillResolver. Takes precedence overskillsand 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 everyapply_skillinvocation'sctxargument before the skill'sbody-fnruns. The harness's keys override anything the model passes for the same key — useful for threading inboundattachments, sender identity, transport, or session info into skill resolution without trusting the model to populate them. Ignored by staticbodyskills (those are returned verbatim).max-iterations— hard cap onrun-loopturns (default 10).max-context-tokens,max-output-tokens,warn-context-pct— reserved for Phase 1.5 token-budget enforcement; ignored today.emit-steps— whentrue,run-looppublishes a per-turn observability record to the current run's stream via::hot::stream/data. Defaults tofalse. Errors from missing stream context are swallowed so opting in is safe in any execution environment.step-data-type— streamdata-typelabel used whenemit-stepsis on. Defaults to"ai:chat:step".emit-stream-deltas— whentrue,run-loop-streammirrors everyReplyDeltato the current run's stream via::hot::stream/data(in addition to invoking any calleron-delta). Defaults tofalse. Errors from missing stream context are swallowed.delta-data-type— streamdata-typelabel used whenemit-stream-deltasis on. Defaults to"ai:chat:delta".count-tokens-fn— provider's(messages, model) -> Intcounter used by pre-call budget checks. When unset, falls back tocount-tokens-heuristic.model-context-window— explicit context-window size in tokens. When unset, the budget check usesmax-context-tokensdirectly 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— parsedToolCallrecords 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 aChatUsage; legacy adapters still populate it with a rawMapand callers usemake-usageto coerce. Phase 1.5 will normalize onChatUsage.raw— the raw provider response for adapter use; opaque torun-loopcallers.
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.provider—display-nameof 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 aStr(plain text). Forrole: "tool"it is the result returned by::ai::tool/dispatch. Forrole: "assistant"turns that called tools it may be empty. Whenpartsis set, adapters that support multi-part input should preferpartsand treatcontentas 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 callflatten-parts(parts)to get a string equivalent.tool-calls— present onrole: "assistant"turns that requested tool use; carries the parsedToolCallrecords.tool-call-id— present onrole: "tool"results, echoing theidof the originatingToolCall.
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 forkind: "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 (Anthropiccache_control, OpenAIimage.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— theProviderenum 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 andprovider-name.chat-fn— tools-aware chat function matching the(model, messages, system, tools) -> ChatReplycontract.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 withstop-reasonand optionalusage.
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.