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 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 |
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 |
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 the calls and add their results to the conversation:
local json = require("json")
local funcs = require("funcs")
local function execute_and_continue(runner, conversation)
while true do
local response, err = runner:step(conversation)
if err then return nil, err end
local tool_calls = response.tool_calls
if not tool_calls or #tool_calls == 0 then
return response.result, nil
end
for _, tc in ipairs(tool_calls) do
local result, call_err = funcs.call(tc.registry_id, tc.arguments)
local result_str
if call_err then
result_str = json.encode({ error = tostring(call_err) })
else
result_str = json.encode(result)
end
conversation:add_function_call(tc.name, json.encode(tc.arguments), tc.id)
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 funcs.call() |
funcs.call(tc.registry_id, tc.arguments) to execute tools. The registry_id field maps directly to the tool's entry in the registry.
For how agent tool access and observability are secured, see the Security Model.
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".
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:
- time_aware
- id: custom_trait
context:
key: value
Built-in Traits
| Trait | Description |
|---|---|
time_aware |
Injects current date and time into the prompt |
The time_aware trait accepts context options:
traits:
- id: time_aware
context:
timezone: America/New_York
time_interval: 15
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 to inject dynamic content
- step_func_id - function called at each step for side effects
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 runner:step() to recall relevant items based on the conversation context. Results are injected as developer messages.
| 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
- Try
wippy.agent:resolvercontract (if bound) - Try registry lookup by ID
- Try registry lookup by name
- Return error if not found
Custom resolution can load agent definitions outside the framework registry, including definitions scoped by user or workspace.
See Also
- LLM — Underlying model interface
- Building an LLM Agent — Build an agent step by step
- Framework Overview — Install and import framework modules