Agents

The wippy/agent module defines agents declaratively and runs them through a context and runner. Agents can use tools, stream responses, delegate work, apply traits, and recall memory.

This page is an API primer with composable reference snippets, not a standalone tutorial. The snippets assume an existing Wippy project, a registered LLM model and provider, configured provider credentials, and the agent, tool, or resolver entries referenced by each example. Later snippets build on variables such as ctx, runner, and conversation created in earlier sections. For a complete runnable project, follow Build an LLM Agent.

Setup

Add the module to your project:

wippy add wippy/agent
wippy install

The agent module declares its wippy/llm dependency itself. Add the agent dependency to source when it is not already present:

version: "1.0"
namespace: app

entries:
  - name: dep.agent
    kind: ns.dependency
    component: wippy/agent
    version: "*"

Agent Definitions

Agents are registry entries with meta.type: agent.gen1:

entries:
  - name: assistant
    kind: registry.entry
    meta:
      type: agent.gen1
      name: assistant
      title: Assistant
      comment: A helpful chat assistant
    prompt: |
      You are a helpful assistant. Be concise and direct.
      Answer questions clearly.      
    model: gpt-4o
    max_tokens: 1024
    temperature: 0.7

Agent Fields

Field Type Description
meta.type string Must be agent.gen1
meta.name string Agent identifier
prompt string System prompt
model string Model name or class
max_tokens number Maximum output tokens (default 512)
temperature number Optional sampling temperature; omitted by default, with range and support determined by the provider
thinking_effort number Forwarded to the model only when > 0 (provider-defined scale)
tools array Tool registry IDs
traits array Trait references
delegates array Delegate agent references
memory array Static memory items (strings)
memory_contract table Dynamic memory configuration
agent_options table Host options, including checkpoint, turn_context and loop settings

Agent Context

Create an agent context, configure it as needed, and then load an agent:

imports:
  agent_context: wippy.agent:context
  prompt: wippy.llm:prompt
local agent_context = require("agent_context")

local ctx = agent_context.new()
local runner, err = ctx:load_agent("app:assistant")
if err then
    error("Failed to load agent: " .. tostring(err))
end

Context Methods

Method Description
agent_context.new(options?) Create new context
:add_tools(specs) Add tools at runtime
:add_delegates(specs) Add delegate agents
:configure_delegate_tools(config) Configure how delegates expose themselves as tools
:set_memory_contract(config) Configure dynamic memory
:set_context_merger(fn) Provide a function to merge runtime context updates
:update_context(updates) Update runtime context
:load_agent(spec_or_id, options?) Load and compile agent, returns runner
:switch_to_agent(id, options?) Switch to different agent, returns (boolean, string?)
:switch_to_model(name) Change model on current agent, returns (boolean, string?)
:get_current_agent() Get current runner
:get_config() Return a summary of the context configuration

Context Options

local ctx = agent_context.new({
    context = { session_id = "abc", user_id = "u1" },
    delegate_tools = { enabled = true },
    enable_cache = true,
})
Option Description
context Base runtime context forwarded to tools and delegates
delegate_tools Default delegate-tool configuration (overridden by configure_delegate_tools)
enable_cache Prompt cache marker setting for Claude models. The current implementation always enables markers, including when this option is false.

Loading by Inline Spec

Load an agent without a registry entry:

local runner, err = ctx:load_agent({
    id = "inline-agent",
    name = "helper",
    prompt = "You are a helpful assistant.",
    model = "gpt-4o",
    max_tokens = 1024,
    tools = { "app.tools:search" },
})

Running Steps

The runner executes one agent step from a prompt-builder conversation:

local prompt = require("prompt")

local conversation = prompt.new()
conversation:add_user("What is the capital of France?")

local response, err = runner:step(conversation)
if err then
    error(tostring(err))
end

print(response.result)

Step Options

local self_pid, pid_err = process.pid()
if pid_err then
    error("Failed to get process PID: " .. tostring(pid_err))
end

local response, err = runner:step(conversation, {
    context = { session_id = "abc" },
    stream_target = { reply_to = self_pid, topic = "stream" },
    tool_call = "auto",
})
if err then
    error("Agent step failed: " .. tostring(err))
end
Option Type Description
context table Runtime context merged with agent context
stream_target table Streaming: { reply_to, topic }
tool_call string "auto", "any", "none", or a tool name
context_id string Conversation ID for LLM usage tracking
route table Previous response's route_pin, retained for the current turn
drop_stale_thinking boolean Strip thinking bound to an earlier system/tool prefix
thinking_is_current function Optional (message, fingerprint) -> boolean replacing the fingerprint comparison
checkpoint table { token_threshold, compact }; callback returns a replacement conversation builder
cancel channel Host closes it to cancel in-flight generation

Step Response

Field Type Description
result string Generated text
tokens table Token usage
finish_reason string Stop reason
tool_calls table? Tool calls to execute
delegate_calls table? Delegate invocations
metadata table? Provider metadata, including thinking_blocks and content_positions
context_notes array? Positional notes sent after the supplied conversation and before the output
prefix_fingerprint string? SHA-256 of the system text and tool definitions actually sent
route_pin table? Route retained after fallback, or the pin passed to this step

Pass response.route_pin back as route on later steps of the same turn, and clear it for a new turn. This keeps tool results on the route that produced their calls after a fallback. Closing cancel returns a stopped response with any text streamed so far and finish_reason = "cancelled".

Persisting Step Context

Persist context_notes in order after the supplied conversation and before the assistant text or calls. These developer messages carry metadata.preserve_position = true; replay their content and metadata unchanged. context_notes replaces the earlier memory_prompt response field. Discard notes when discarding the step's output. Recall cooldown reads metadata.memory_ids from the persisted notes.

Store prefix_fingerprint as metadata.prefix_fingerprint on assistant rows carrying thinking, together with metadata.thinking_blocks and any metadata.content_positions. Set drop_stale_thinking = true on subsequent steps. The runner removes blocks through the last stale message, including messages without a fingerprint. It works on copies and recomputes the fingerprint if a checkpoint replaces the conversation.

The fingerprint covers the compiled system prompt, ordinary conversation system text and provider-visible tool definitions. It excludes the model and registry-only metadata. It reflects the Claude/Bedrock prefix; equality on other drivers is necessary but does not prove identical wire input. Edits to earlier conversation messages are not detected. Hosts with a checkpoint epoch can supply thinking_is_current(message, fingerprint) to check both.

The current tool round keeps its thinking, with stale blocks marked for the driver to bind or omit. An older stale turn with an unanswered call fails before dispatch. See thinking block binding for provider handling.

Runner Stats

local stats = runner:get_stats()
-- stats.id, stats.name, stats.total_tokens

Tool Definitions

Tools are function.lua entries with meta.type: tool. Define them in a separate _index.yaml:

version: "1.0"
namespace: app.tools

entries:
  - name: calculate
    kind: function.lua
    meta:
      type: tool
      title: Calculate
      input_schema: |
        {
          "type": "object",
          "properties": {
            "expression": {
              "type": "string",
              "description": "Math expression to evaluate"
            }
          },
          "required": ["expression"],
          "additionalProperties": false
        }        
      llm_alias: calculate
      llm_description: Evaluate a mathematical expression.
    source: file://calculate.lua
    modules: [expr]
    method: handler
local expr = require("expr")

local function handler(args)
    local result, err = expr.eval(args.expression)
    if err then
        return { error = tostring(err) }
    end
    return { result = result }
end

return { handler = handler }

Tool Metadata

Field Type Description
meta.type string Must be tool
meta.input_schema string/table JSON Schema for tool arguments
meta.llm_alias string Name exposed to the LLM
meta.llm_description string Description exposed to the LLM
meta.exclusive boolean If true, cancels concurrent tool calls

Referencing Tools in Agents

List tool registry IDs in the agent definition:

  - name: assistant
    kind: registry.entry
    meta:
      type: agent.gen1
      name: assistant
    prompt: You are a helpful assistant with tools.
    model: gpt-4o
    max_tokens: 1024
    tools:
      - app.tools:calculate
      - app.tools:search
      - app.tools:*          # wildcard: all tools in namespace

Tools can also be referenced with custom aliases and context:

    tools:
      - id: app.tools:search
        alias: web_search
        context:
          api_key: "${SEARCH_API_KEY}"

Tool Execution

When an agent step returns tool_calls, execute them through the tool caller. Declare its import and the json module on the calling entry:

modules: [json]
imports:
  tool_caller: wippy.agent.tools:caller

This loop handles registered tools; a host using delegates must also settle delegate_calls.

local json = require("json")
local tool_caller = require("tool_caller")

local function execute_and_continue(runner, conversation)
    local caller = tool_caller.new():set_strategy(tool_caller.STRATEGY.PARALLEL)
    local route_pin = nil
    while true do
        local response, err = runner:step(conversation, {
            route = route_pin,
            drop_stale_thinking = true,
        })
        if err then return nil, err end
        if response.truncation_reason then return nil, "Agent output was truncated" end
        route_pin = response.route_pin

        for _, note in ipairs(response.context_notes or {}) do
            conversation:add_message(note.role, note.content, note.name, note.metadata)
        end
        local metadata = response.metadata or {}
        metadata.prefix_fingerprint = response.prefix_fingerprint
        conversation:add_assistant(response.result or "", metadata)

        local tool_calls = response.tool_calls
        if not tool_calls or #tool_calls == 0 then
            return response.result, nil
        end

        -- Keep the response's calls together, then append results in call order.
        for _, tc in ipairs(tool_calls) do
            conversation:add_function_call(tc.name, json.encode(tc.arguments), tc.id, {
                provider_metadata = tc.provider_metadata,
            })
        end
        local validated, validation_err = caller:validate(tool_calls)
        if validation_err then return nil, validation_err end
        local results, execute_err = caller:execute({}, validated)
        if execute_err then return nil, execute_err end
        for _, tc in ipairs(tool_calls) do
            local result = results[tc.id]
            local result_str = json.encode(result.error
                and { error = tostring(result.error) } or result.result)
            conversation:add_function_result(tc.name, result_str, tc.id)
        end
    end
end

Tool Call Fields

Field Type Description
id string Unique call identifier
name string Tool name (alias or llm_alias)
arguments table Parsed arguments
registry_id string Full registry ID for execution
provider_metadata table? Pass unchanged in add_function_call options
The caller validates calls and applies execution context. Hosts can configure wrappers with set_tool_wrappers(runner.tool_wrappers) and supply their refs with set_wrapper_context(...). Its default strategy is SEQUENTIAL; select PARALLEL to run a batch concurrently. A result of { success = false, error = "non-empty text" } sets ToolExecResult.error while preserving the original result. An error field alone remains ordinary result data.

Result Listener

Register a listener to persist results as calls finish:

local caller = tool_caller.new():set_strategy(tool_caller.STRATEGY.PARALLEL)
caller:set_result_listener(function(call_id, result)
    local _, err = store:save_observation(call_id, result)
    if err then return nil, err end
end)

Here store:save_observation is the host's persistence function. The listener receives each call ID once, including validation failures, tool errors and cancellation, before after_execute wrappers. Parallel delivery follows completion order; sequential delivery finishes before the next call starts. The listener runs synchronously, so Stop waits for it to return. Keep it short and do not mutate the result.

Raising or returning a non-nil second value fails delivery. The caller cancels running calls, starts no more, skips after_execute, and returns nil, error naming the failed ID. Already accepted results remain accepted. set_result_listener(nil) removes the listener. Hosts can pass a cancel channel as the third argument to execute(context, validated, cancel).

For how agent tool access and observability are secured, see the Security Model.

Attention context and UI actions

When a user message contains a validated wippy.attention version 1 through 4 attachment, the prompt builder renders bounded candidates into the same user-role turn. The rendered block is marked untrusted_user_observation; labels, accessible text, and values are data and cannot supply model instructions. Unknown attachment kinds or versions remain inert until a handler is registered. Prompt rendering has its own byte budget and does not mutate the persisted attachment.

Files attached to a user message are listed to the model by file name, type, size and ID. Their content is never inlined as an image. Image content reaches the model only through an approved wippy.attention.visual capture, described in Visual capture.

Attention trait and tools

Add wippy.agent.traits:attention to an agent to give it the Attention tools. All of them are private tools in the wippy.agent.tools namespace. Reads work with automatic attachment, message-context capability and overlay permission off. An incoming attachment does not grant tools.

Keep the agent's existing model, prompt and unrelated traits. Add the reusable trait as one additional item:

traits:
  - id: wippy.agent.traits:attention

The trait supplies its read guard and tools. A bare standalone agent runner does not create a browser connection; live reads also need the Session broker and a turn bound to an authenticated Host tab. See the Attention quickstart for a full agent entry, Host configuration and public package examples.

The nine read tools inspect the current interface of the Host tab that submitted the turn:

Tool Example arguments Purpose
attention_find_semantic {"role":"button","name":"Save"} Match role, name, text or resource_id across the permitted tree.
attention_find_css {"selector":"button","root":ref} Query one known document or shadow root.
attention_get_node {"node_id":"returned-canonical-id"} Resolve one known canonical node.
attention_get_tree {"scope":ref,"limit":16,"depth":2} Read a bounded subtree page.
attention_get_geometry {"node":ref} Read geometry, coordinate space and quality.
attention_get_cursor {} Read the latest pointer observation.
attention_get_focus {} Read the focused target.
attention_get_selection {} Read selected text and both endpoint paths.
attention_hit_test {"x":100,"y":120} Inspect a point, by default with a 20 CSS-pixel radius and a 5-pixel grid.

In the examples, ref is the complete returned NodeRef with host_instance_id, node_id, mount_id and generation. The older attention_inspect tool was removed; the explicit read tools above replace it.

attention_context_set turns automatic pointing context on or off for the current Session. It takes a required boolean enabled and an optional expected_revision. The trait instructs the agent to call it only when the user requests a change. There is no additional confirmation prompt. The setting belongs to the Session, so it stays in effect after the user switches to another agent.

The four action tools ask the user to clarify or capture a target through the current authenticated Host connection:

Tool Purpose Targets
ui_action_highlight Highlight candidate rectangles. 1 to 32
ui_action_confirm Ask whether a supplied candidate is the intended one. 1 to 32
ui_action_select Ask the user to select a candidate, or to select an arbitrary area when there are no targets and capture_region: true. 0 to 32
ui_action_capture_visual Ask the user to approve an image of a target, region or viewport. The capture object sets scope (target, region or viewport), region, allow_adjustment, allow_viewport_choice and format (image/png or image/webp). 1 to 32

The action tools and attention_context_set are exclusive, so each one cancels concurrent tool calls. See agent clarification actions for every argument and default.

Read limits and history

Semantic search and CSS search are separate modes; CSS requires one canonical document or shadow-root reference. Search returns at most eight matches and tree pages at most 32 nodes. Private results use wippy.attention.model.v1, preserve complete returned ancestry through local dictionaries, and are capped at 8 KiB.

The trait guard admits one read per batch and four attempted reads per user turn, including invalid and refused attempts. Two invalid attempts end repair. It refuses reads when trusted history cannot establish the turn boundary. A refused call keeps the model's own call ID, tool name and arguments. The refusal reason travels to the tool in its execution context, and the tool returns a rejected result without contacting the Host. Unrelated tools in a mixed batch keep their normal behavior. These limits are not a global provider-generation or cost limit. See the complete limits.

Session stores each compact read result in private function history. Later model generations can reuse it while it remains valid. A result expires after 30 seconds, on a later user turn, after a completed browser action, or when a newer read replaces the same query. Tree reads also become stale when a later read reports a changed tree revision. Geometry reads use the geometry revision; point reads use both. Cursor, focus and selection use query replacement rather than the shared observation revision. Withdrawal uses existing metadata.stale behavior and preserves valid tool-call/result pairs. The result stays stored, but the model receives a stale-result notice instead of the old observation. Read lifetimes are measured from the time the server received the result, not from browser clocks. Immutable automatic attachments leave the model view after expiry, a later user turn, a fresh usable read or a completed browser action. Their stored content and hashes remain unchanged.

Target references

Each item of an action tool's targets array is an immutable target reference containing the snapshot, target, Host instance, mount ID and generation, path digest, rectangle, and optional label. It contains no capability or bearer token. The prompt renderer exposes this object as a candidate's action_ref, and a read that returns exactly one actionable node exposes it as target_ref. The agent must copy that exact object verbatim as one item of the tool's targets array, and must not build one from IDs, labels or coordinates. Tool arguments can also include a prompt of at most 512 characters and independent pointer and keyboard permissions. A zero-target select request is valid only with capture_region: true. Here capture_region enables Host-owned arbitrary area selection; it does not authorize screenshot capture.

The tools receive ephemeral ui_action_runtime context only at exact registered tool execution. The context is excluded from validated tool-call records, wrappers, persistence, history, and model-visible payloads. The session broker rejects a tool when the current turn has no eligible Host connection, when interactive actions are disabled, or when its target belongs to another Host instance. Read-only inspection has independent authority. Requests bind to the Host tab that submitted the current turn; reconnect requires a fresh authenticated binding and cannot replay an expired request.

The Session plugin is the sole WebSocket inbox owner. It validates incoming action results and routes each accepted result to a private per-call mailbox. Framework tools wait on that mailbox rather than subscribing to WebSocket topics themselves.

The tool path waits for one terminal Host result. Its status is selected, confirmed, prepared, cancelled, denied, rejected, expired, stale, disconnected, permission-denied, unavailable or error. See terminal statuses for the meaning of each one. The broker permits one pending action per session, caps expiry at 120 seconds, and accepts only the first correctly correlated terminal result.

Visual capture

ui_action_capture_visual opens explicit Host approval and prepares a removable ordinary composer file. It never sends the message. Target or region capture is the default, and viewport capture is offered only when the request sets allow_viewport_choice. The approved image is uploaded as an ordinary file, so the application's upload type must accept image/png, and image/webp when that format is requested.

When the user later sends a message that still includes the file, the Host adds one wippy.attention.visual attachment that references the upload. This happens even when automatic pointing context is off. Semantic Attention attachments contain no screenshot bytes or upload references.

The session upload authorizer and byte resolver are implemented through the content-provider contract. Before persistence, the session layer validates reference structure, session binding, expiry, media type, size, and digest, then authorizes the reference and verifies the resolved bytes. An attached reference that cannot pass these checks rejects the complete send atomically; it is not silently removed to persist a semantic-only message.

When rendering an accepted message, the framework emits image input only when an authorized visual_resolver returns bytes that pass media-type, size, and SHA-256 checks. Later expiry, denied access, an unavailable resolver, or invalid bytes omits the image and returns a render diagnostic without changing the persisted message; other valid semantic context remains renderable.

Deployments must verify capture, redaction, ordinary-file authorization, expiry and orphan cleanup independently. A byte resolver alone does not establish those behaviors.

Errors during a turn

A model or provider error ends only the current turn. The user sees the error and can send the message again in the same Session. If the Session process stopped because of a crash, the next message reopens it.

See Web Host Attention Context for the overlay behavior and Relay for connection targeting.

Streaming

Stream agent responses through stream_target:

local TOPIC = "agent_stream"

local function stream_step(runner, conversation)
    local stream_ch, listen_err = process.listen(TOPIC)
    if listen_err then
        return nil, nil, listen_err
    end

    local function finish(text, response, err)
        local ok, cleanup_err = process.unlisten(stream_ch)
        if not ok then
            cleanup_err = cleanup_err or "Failed to remove agent stream listener"
            if err then
                return text, nil, tostring(err) .. "; cleanup failed: " .. tostring(cleanup_err)
            end
            return text, nil, cleanup_err
        end
        if err then
            return text, nil, err
        end
        return text, response, nil
    end

    local self_pid, pid_err = process.pid()
    if pid_err then
        return finish("", nil, pid_err)
    end

    local done_ch = channel.new(1)
    coroutine.spawn(function()
        local response, err = runner:step(conversation, {
            stream_target = {
                reply_to = self_pid,
                topic = TOPIC,
            },
        })
        done_ch:send({ response = response, err = err })
    end)

    local full_text = ""
    local step_result = nil
    local stream_done = false
    local stream_err = nil

    while true do
        local cases = {}
        if not stream_done then
            table.insert(cases, stream_ch:case_receive())
        end
        if not step_result then
            table.insert(cases, done_ch:case_receive())
        end

        local result = channel.select(cases)
        if not result.ok then
            return finish(full_text, nil, "Agent stream closed before completion")
        end

        if result.channel == done_ch then
            step_result = result.value
            if step_result.err then
                return finish(full_text, nil, step_result.err)
            end
            if stream_done then
                return finish(full_text, step_result.response, stream_err)
            end
        else
            local chunk = result.value
            if chunk.type == "chunk" then
                local content = chunk.content or ""
                print(content)
                full_text = full_text .. content
            elseif chunk.type == "error" then
                stream_done = true
                stream_err = chunk.error and chunk.error.message or "Agent stream failed"
            elseif chunk.type == "done" then
                stream_done = true
            end

            if stream_done and step_result then
                return finish(full_text, step_result.response, stream_err)
            end
        end
    end
end

The stream uses the same chunk types as direct LLM streaming: "chunk", "thinking", "tool_call", "error", "done".

Use coroutine.spawn to run runner:step() in a separate coroutine so you can receive stream chunks concurrently. Use channel.select to multiplex the stream and completion channels.

Delegates

Agents can delegate to other agents. Delegates appear as tools to the parent agent:

  - name: coordinator
    kind: registry.entry
    meta:
      type: agent.gen1
      name: coordinator
    prompt: Route questions to the right specialist.
    model: gpt-4o
    max_tokens: 1024
    delegates:
      - id: app:code_agent
        name: ask_coder
        rule: for programming questions
      - id: app:math_agent
        name: ask_mathematician
        rule: for math problems

Delegate calls appear in response.delegate_calls:

local response, err = runner:step(conversation)
if err then
    error("Delegate step failed: " .. tostring(err))
end

if response.delegate_calls then
    for _, dc in ipairs(response.delegate_calls) do
        -- dc.agent_id - target agent registry ID
        -- dc.name - delegate tool name
        -- dc.arguments - forwarded message
    end
end

Delegates can also be added at runtime:

ctx:add_delegates({
    { id = "app:specialist", name = "ask_specialist", rule = "for domain questions" },
})

Traits

Traits are reusable definitions that contribute prompts, tools, and behavior to agents:

  - name: assistant
    kind: registry.entry
    meta:
      type: agent.gen1
      name: assistant
    prompt: You are a helpful assistant.
    model: gpt-4o
    traits:
      - wippy.agent.traits:time_aware
      - id: app:custom_trait
        context:
          key: value

Built-in Traits

Trait Description
wippy.agent.traits:time_aware Declares per-turn time context for the host to render and store
wippy.agent.traits:attention Adds the Attention read tools, attention_context_set and the UI action tools. See Attention context and UI actions.

The time_aware trait accepts context options:

    traits:
      - id: wippy.agent.traits:time_aware
        context:
          timezone: America/New_York
          time_format: "2006-01-02 15:04 MST"

The build function contributes agent_options.turn_context.time = { timezone, format } and no prompt text. timezone defaults to UTC; time_format is an optional Go time layout. The legacy time_interval is ignored. Hosts render once per user turn with wippy.agent:turn_context and persist the note after the user message:

modules: [time]
imports:
  turn_context: wippy.agent:turn_context
local turn_context = require("turn_context")
local time = require("time")

local note, err = turn_context.render(runner.agent_options, time.now())
if err then return nil, err end
if note then
    conversation:add_system(note, { preserve_position = true })
end

Keep this note unchanged on later steps. Session and Dataflow hosts persist it automatically. Unknown or empty timezones and invalid formats fail rendering; a malformed time_format fails trait compilation.

The wippy/session module, version 0.6.3 or newer, provides two traits for live session input:

Trait Description
wippy.session.traits:steering Defaults the active agent to accepting messages while a turn is running.
wippy.session.traits:input_control Grants set_session_input_policy, which can change the current turn or persistent session policy. It does not enable steering by itself.

See Sessions for policy order, tool scopes, Stop behavior, and the public interaction state.

Custom Traits

Traits are registry entries with meta.type: agent.trait. They can contribute:

  • prompt - static text appended to the system prompt
  • build_func_id - function called at compile time to contribute tools, prompts, delegates
  • prompt_func_id - function called at each step whose returned system text must stay stable across the conversation
  • step_func_id - function called at each step for side effects

Changing per-step context belongs in persisted positional notes. Lifecycle messages from activate and before_step must use developer or system; Session and Dataflow reject other roles. A failed prompt function fails the step before provider dispatch.

Behaviors and Compaction

Put behaviors directly on the trait registry entry, alongside meta; do not nest it under data. Attach a trait to set per-agent checkpoint defaults:

entries:
  - name: compaction
    kind: registry.entry
    meta:
      type: agent.trait
      name: Compaction
    behaviors:
      compaction:
        handles: [checkpoint]
        checkpoint:
          token_threshold: 12000
          model: class:fast
          temperature: 0.2
          max_tokens: 3000

  - name: assistant
    kind: registry.entry
    meta:
      type: agent.gen1
      name: assistant
    prompt: You are a helpful assistant.
    model: gpt-4o
    traits: [app:compaction]
    agent_options:
      checkpoint:
        token_threshold: 16000

This behavior has no checkpoint handler and configures the host's default summarizer. Only behaviors without a checkpoint handler (handlers.checkpoint or the shared handler) promote model, temperature, Session's max_tool_result_chars, max_conversation_chars, max_tool_results, max_tool_results_chars, and Dataflow's max_row_chars into agent_options.checkpoint. With a handler, those keys remain local to its binding. The keys enabled, function_id, max_memory_chars, max_tokens, strict and token_threshold are promoted either way. Other checkpoint keys are not promoted.

Behavior options merge in this order: trait options, behavior options, attachment options, phase options. Maps compile in key order; arrays retain declaration order. Checkpoint scheduling merges trait defaults, explicit agent_options.checkpoint, then host config.checkpoint. Session-wide defaults precede the agent values. Maps merge recursively; lists replace, including empty lists. False, zero and empty strings are explicit overrides.

The detached effective checkpoint options override binding defaults and reach both the binding and function fallback. Bindings run first; a strict binding failure prevents fallback. Absent configuration does not enable checkpointing. See Sessions and Dataflow for scheduling and sizing.

Tool wrappers must preserve every original call ID exactly once, even when redirecting tools or reordering calls. Missing, duplicated or added IDs fail validation before execution.

Memory

Static Memory

Static memory items are appended to the system prompt:

  - name: assistant
    kind: registry.entry
    meta:
      type: agent.gen1
      name: assistant
    prompt: You are a helpful assistant.
    model: gpt-4o
    memory:
      - "User prefers concise answers"
      - "Always cite sources when possible"

Dynamic Memory Contract

Configure dynamic memory recall through an external implementation:

    memory_contract:
      implementation_id: app:memory_store
      context:
        user_id: "${user_id}"
      options:
        max_items: 3
        max_length: 1000
        recall_cooldown: 1
        min_conversation_length: 2

The memory contract is called during eligible steps to recall relevant background context. Results are sent as positional developer messages and returned in context_notes for the host to persist. Recall failures do not stop generation; memory_recall_error describes the failure. An empty memory list is not an error.

Option Default Description
max_items 3 Maximum memory items per recall
max_length 1000 Maximum total character length
recall_cooldown 1 Minimum steps between recalls
min_conversation_length 2 Minimum conversation turns before first recall

Resolver Contract

When load_agent() receives a string identifier, it first tries to resolve it through the wippy.agent:resolver contract. If no resolver is bound or the resolver returns nil, it falls back to the registry lookup.

This allows applications to implement custom agent resolution, such as loading agent definitions from a database.

Binding a Resolver

Define a resolver function and bind it to the contract:

entries:
  - name: agent_resolver.resolve
    kind: function.lua
    source: file://agent_resolver.lua
    method: resolve
    modules:
      - logger
    imports:
      agent_registry: wippy.agent.discovery:registry

  - name: agent_resolver_binding
    kind: contract.binding
    contracts:
      - contract: wippy.agent:resolver
        default: true
        methods:
          resolve: app:agent_resolver.resolve

Resolver Implementation

The resolver receives { agent_id = "..." } and returns an agent spec table or nil:

local agent_registry = require("agent_registry")

local CUSTOM_PREFIX = "custom:"

function resolve(args)
    local agent_id = args.agent_id
    if not agent_id then
        return nil, "agent_id is required"
    end

    if agent_id:sub(1, #CUSTOM_PREFIX) == CUSTOM_PREFIX then
        local id = agent_id:sub(#CUSTOM_PREFIX + 1)

        -- load from database, config file, or any other source
        return {
            id = agent_id,
            name = "custom-agent",
            prompt = "You are a custom agent.",
            model = "class:balanced",
            max_tokens = 1024,
            tools = {},
        }
    end

    -- fall back to registry
    local spec, err = agent_registry.get_by_id(agent_id)
    if not spec then
        spec, err = agent_registry.get_by_name(agent_id)
    end
    return spec, err
end

return {
    resolve = resolve,
}

Resolution Order

  1. Try wippy.agent:resolver contract (if bound)
  2. Try registry lookup by ID
  3. Try registry lookup by name
  4. Return error if not found

Custom resolution can load agent definitions outside the framework registry, including definitions scoped by user or workspace.

See Also