local M = {}
local kernel = require("knl")
local Outcome = kernel.Outcome
local adapter = require("knl_adapter")
local proto = require("llm_proto")
local proto_openai = proto.adapter("openai")
local lshape = require("lshape")
local T = lshape.t
local shape = lshape.check
local PORTS = {
anthropic = adapter.anthropic,
openai = adapter.openai,
}
local USAGE = T.shape({
input_tokens = T.number,
output_tokens = T.number,
total_tokens = T.number,
thinking_tokens = T.number:is_optional(),
})
local RUN_OK = T.shape({
ok = T.literal(true),
content = T.string,
usage = USAGE,
num_turns = T.number,
messages = T.array_of(T.table),
}, { open = false })
local RUN_ERR = T.shape({
ok = T.literal(false),
error = T.string,
usage = USAGE,
num_turns = T.number,
messages = T.array_of(T.table),
}, { open = false })
local RUN_RESULT = T.any_of({ RUN_OK, RUN_ERR })
local MCP_CALL_RESULT = T.shape({
ok = T.boolean,
error = T.string:is_optional(),
content = T.table:is_optional(),
is_error = T.boolean:is_optional(),
structured_content = T.any:is_optional(),
}, { open = false })
local function tool_use_blocks(content)
local out = {}
for _, block in ipairs(content or {}) do
if block.type == "tool_use" then
out[#out + 1] = block
end
end
return out
end
local function text_of(content)
local parts = {}
for _, block in ipairs(content or {}) do
if block.type == "text" and block.text then
parts[#parts + 1] = block.text
end
end
return table.concat(parts, "\n")
end
local function resolve_mcp_group(tool_json, server_name)
local meta = tool_json._meta
if type(meta) == "table" then
local g = meta.group
if type(g) == "string" and g ~= "" then
return g
end
end
return server_name
end
local function group_set_of(active_groups)
if not active_groups or #active_groups == 0 then
return nil
end
local set = {}
for _, g in ipairs(active_groups) do
set[g] = true
end
return set
end
local function in_groups(group, group_set)
if group_set == nil then
return true
end
return group_set[group or "default"] == true
end
local function registry_candidates()
local out = {}
for _, spec in ipairs(tool.schema()) do
local name = spec.name
out[#out + 1] = {
group = spec.group,
bind = {
name = name,
description = spec.description,
input_schema = spec.input_schema,
handler = function(input)
return tool.call(name, input)
end,
},
}
end
return out
end
local function extra_candidates(extra_tools)
local out = {}
for _, t in ipairs(extra_tools or {}) do
local name = t.name
local bind
if t.schema and t.handler then
bind = {
name = name,
description = t.schema.description,
input_schema = t.schema.input_schema,
handler = t.handler,
}
else
bind = {
name = name,
description = t.description,
input_schema = t.input_schema,
schema = t.schema,
handler = t.handler or function(input)
return tool.call(name, input)
end,
}
end
out[#out + 1] = { group = t.group, bind = bind }
end
return out
end
local function build_tools(candidates, active_groups)
local group_set = group_set_of(active_groups)
local binds = {}
for _, c in ipairs(candidates) do
if in_groups(c.group, group_set) then
binds[#binds + 1] = c.bind
end
end
return adapter.tools(binds)
end
local function resource_candidates(sn)
return {
{
group = sn,
bind = {
name = sn .. "__mcp_list_resources",
description = "List available resources on MCP server '" .. sn .. "'",
input_schema = { type = "object", properties = {} },
handler = function(_input)
local r = mcp.list_resources(sn)
if not r.ok then
return std.json.encode({ error = r.error })
end
return std.json.encode(r.resources)
end,
},
},
{
group = sn,
bind = {
name = sn .. "__mcp_read_resource",
description = "Read a resource by URI from MCP server '" .. sn .. "'",
input_schema = {
type = "object",
properties = { uri = { type = "string" } },
required = { "uri" },
},
handler = function(input)
local r = mcp.read_resource(sn, input.uri)
if not r.ok then
return std.json.encode({ error = r.error })
end
return std.json.encode(r.contents)
end,
},
},
}
end
local function prompt_candidates(sn)
return {
{
group = sn,
bind = {
name = sn .. "__mcp_list_prompts",
description = "List available prompts on MCP server '" .. sn .. "'",
input_schema = { type = "object", properties = {} },
handler = function(_input)
local r = mcp.list_prompts(sn)
if not r.ok then
return std.json.encode({ error = r.error })
end
return std.json.encode(r.prompts)
end,
},
},
{
group = sn,
bind = {
name = sn .. "__mcp_get_prompt",
description = "Get a prompt by name from MCP server '" .. sn .. "'",
input_schema = {
type = "object",
properties = {
name = { type = "string" },
args = { type = "object" },
},
required = { "name" },
},
handler = function(input)
local r = mcp.get_prompt(sn, input.name, input.args or {})
if not r.ok then
return std.json.encode({ error = r.error })
end
return std.json.encode(r.messages)
end,
},
},
}
end
local function append_all(into, more)
for _, item in ipairs(more) do
into[#into + 1] = item
end
end
local function wire_progress(sn, opts)
if opts.on_progress then
local user_cb = opts.on_progress
mcp.on_progress(sn, function(ev)
local ok, cb_err = pcall(user_cb, ev)
if not ok then
log.warn("agent: on_progress callback error: " .. tostring(cb_err))
end
end)
elseif opts.progress_to_log then
mcp.on_progress(sn, function(ev)
local msg = "mcp progress: server="
.. tostring(ev.server)
.. " token="
.. tostring(ev.token)
.. " p="
.. tostring(ev.progress)
.. "/"
.. tostring(ev.total or "")
if ev.message and ev.message ~= "" then
msg = msg .. " msg=" .. ev.message
end
log.info(msg)
end)
end
end
local function wire_log(sn, opts)
if opts.on_log then
local user_cb = opts.on_log
mcp.on_log(sn, function(ev)
local ok, cb_err = pcall(user_cb, ev)
if not ok then
log.warn("agent: on_log callback error: " .. tostring(cb_err))
end
end)
else
mcp.on_log(sn, function(ev)
local msg = "mcp log: server="
.. tostring(ev.server)
.. " logger="
.. tostring(ev.logger)
.. " data="
.. tostring(ev.data)
if ev.level == "debug" then
log.debug(msg)
elseif ev.level == "warning" then
log.warn(msg)
elseif ev.level == "error" then
log.error(msg)
else
log.info(msg)
end
end)
end
end
local function wire_capabilities(sn, opts)
local candidates = {}
if not (opts.enable_resources or opts.enable_prompts or opts.on_log or opts.log_to_stderr) then
return candidates
end
local si_result = mcp.server_info(sn)
if not si_result.ok then
log.warn("agent: mcp.server_info failed for '" .. sn .. "': " .. tostring(si_result.error))
return candidates
end
local caps = (si_result.server_info and si_result.server_info.capabilities) or {}
if opts.enable_resources then
if caps.resources ~= nil then
append_all(candidates, resource_candidates(sn))
else
log.info("agent: server '" .. sn .. "' has no resources capability; skipping register")
end
end
if opts.enable_prompts then
if caps.prompts ~= nil then
append_all(candidates, prompt_candidates(sn))
else
log.info("agent: server '" .. sn .. "' has no prompts capability; skipping register")
end
end
if opts.on_log or opts.log_to_stderr then
if caps.logging ~= nil then
wire_log(sn, opts)
else
log.info("agent: server '" .. sn .. "' has no logging capability; on_log/log_to_stderr skipped")
end
end
return candidates
end
local function mcp_tool_candidates(sn, active_groups)
local list = mcp.list_tools(sn)
if not list.ok then
return nil, "mcp list_tools failed for '" .. sn .. "': " .. tostring(list.error)
end
local group_set = group_set_of(active_groups)
local candidates = {}
for _, entry in ipairs(list.tools or {}) do
local group = resolve_mcp_group(entry, sn)
if in_groups(group, group_set) then
candidates[#candidates + 1] = { group = group, bind = adapter.ToolPort.mcp(sn, entry) }
end
end
return candidates
end
local function connect_mcp_servers(servers, opts)
local candidates = {}
local connected = {}
for _, srv in ipairs(servers) do
local name = srv.name
local ok, err
if srv.url then
local transport_opts = {}
for k, v in pairs(srv.transport_opts or {}) do
transport_opts[k] = v
end
if transport_opts.trace_context == nil then
transport_opts.trace_context = not not srv.trace_context
end
ok, err = pcall(mcp.connect_http, name, srv.url, transport_opts)
else
local connect_opts = { trace_context = not not srv.trace_context }
ok, err = pcall(mcp.connect, name, srv.command, srv.args or {}, connect_opts)
end
if not ok then
return nil, "mcp connect failed for '" .. name .. "': " .. tostring(err), connected
end
table.insert(connected, name)
if opts.sampling then
local sampling_ok, sampling_err = pcall(mcp.set_sampling_handler, name, opts.sampling)
if not sampling_ok then
log.warn("agent: mcp set_sampling_handler failed for '" .. name .. "': " .. tostring(sampling_err))
end
end
local bound, list_err = mcp_tool_candidates(name, opts.tool_groups)
if list_err then
return nil, list_err, connected
end
append_all(candidates, bound)
wire_progress(name, opts)
append_all(candidates, wire_capabilities(name, opts))
end
return candidates, nil, connected
end
local function disconnect_mcp_servers(server_names)
for _, name in ipairs(server_names) do
local ok, err = pcall(mcp.disconnect, name)
if not ok then
log.warn("agent: mcp disconnect error for '" .. name .. "': " .. tostring(err))
end
end
end
local DEFAULT_CONTEXT_MANAGEMENT = {
edits = {
{
type = "clear_tool_uses_20250919",
trigger = { type = "input_tokens", value = 80000 },
keep = { type = "tool_uses", value = 3 },
clear_at_least = { type = "input_tokens", value = 10000 },
},
},
}
local AGENT_OPTS = {
prompt = true,
history = true,
system = true,
store = true,
mcp_servers = true,
extra_tools = true,
tool_groups = true,
on_turn = true,
max_iterations = true,
max_tokens_budget = true,
sampling = true,
on_progress = true,
progress_to_log = true,
on_log = true,
log_to_stderr = true,
enable_resources = true,
enable_prompts = true,
context_management = true,
context_management_config = true,
}
local function resolve_context_management(opts)
if opts.context_management == false then
return nil
end
return opts.context_management_config or DEFAULT_CONTEXT_MANAGEMENT
end
local function port_conf(opts, provider)
local conf = {}
for key, value in pairs(opts) do
if not AGENT_OPTS[key] then
conf[key] = value
end
end
conf.max_tokens = opts.max_tokens or 4096
conf.timeout = opts.timeout or 120
if provider ~= "openai" then
conf.context_management = resolve_context_management(opts)
end
return conf
end
local function warn_anthropic_only(opts, provider)
if provider ~= "openai" then
return
end
for _, name in ipairs({ "cache_control", "context_management", "context_management_config" }) do
if opts[name] ~= nil then
log.warn("agent: " .. name .. " is anthropic-only; ignored for provider=openai")
end
end
end
local function seed_message(session, message)
local content = message.content
if message.role == "assistant" then
session:append({ kind = "llm_response", data = { content = content } })
return
end
if type(content) ~= "table" then
session:append({ kind = "msg_user", data = { content = content } })
return
end
local rest = {}
for _, block in ipairs(content) do
if type(block) == "table" and block.type == "tool_result" then
session:append({
kind = "tool_result",
data = {
call_id = block.tool_use_id,
ok = block.is_error ~= true,
result = block.content or "",
},
})
else
rest[#rest + 1] = block
end
end
if #rest > 0 then
session:append({ kind = "msg_user", data = { content = rest } })
end
end
local CORRELATION_ENV = {
{ key = "trace_id", var = "AGENT_BLOCK_TRACE_ID" },
{ key = "run_id", var = "AGENT_BLOCK_RUN_ID" },
{ key = "agent_id", var = "AGENT_BLOCK_AGENT_ID" },
{ key = "agent_name", var = "AGENT_BLOCK_AGENT_NAME" },
}
local function prompt_meta()
local meta = { label = "prompt" }
for _, entry in ipairs(CORRELATION_ENV) do
local value = std.env.get(entry.var)
if value ~= nil and value ~= "" then
meta[entry.key] = value
end
end
return meta
end
local function seed(session, opts)
for _, message in ipairs(opts.history or {}) do
seed_message(session, message)
end
session:append({ kind = "msg_user", meta = prompt_meta(), data = { content = opts.prompt } })
end
local function zero_usage()
return { input_tokens = 0, output_tokens = 0, total_tokens = 0, thinking_tokens = 0 }
end
local function usage_of(session)
local rows = kernel.views.usage(session)
local row = (rows and rows[1]) or {}
local input = tonumber(row.input_tokens) or 0
local output = tonumber(row.output_tokens) or 0
return {
input_tokens = input,
output_tokens = output,
total_tokens = input + output,
thinking_tokens = tonumber(row.thinking_tokens) or 0,
}
end
local function error_text(o)
local detail = o.detail
if type(detail) ~= "table" then
return tostring(o.kind) .. ": " .. tostring(detail)
end
local message = tostring(detail.message or "unknown failure")
if detail.kind ~= nil then
return tostring(o.kind) .. ": " .. tostring(detail.kind) .. ": " .. message
end
return tostring(o.kind) .. ": " .. message
end
local function refusal_text(o)
local text = "model refused to respond (kind=" .. tostring(o.reason) .. ")"
local detail = o.detail
local said = type(detail) == "table" and type(detail.refusal) == "table" and detail.refusal.detail or nil
if type(said) == "string" and said ~= "" then
text = text .. ": " .. said
end
return text
end
local function fire_on_turn(on_turn, turn_number, answer, calls)
if not on_turn then
return nil
end
local ok, verdict = pcall(on_turn, {
turn_number = turn_number,
content = answer.content,
tool_calls = calls,
usage = answer.usage,
})
if not ok then
log.warn("agent: on_turn callback error: " .. tostring(verdict))
return nil
end
return verdict
end
local function run_loop(opts, provider, candidates, max_iter)
local device = kernel.device({
llm = PORTS[provider]:open(port_conf(opts, provider)),
tools = build_tools(candidates, opts.tool_groups),
system = opts.system,
})
local limit = opts.max_tokens_budget
return kernel.session({
owner = "agent",
budget = { amount = max_iter, tag = "beats", desc = "one unit per beat" },
store = opts.store,
}, function(s)
seed(s, opts)
local turns, content, failure = 0, "", nil
local usage = zero_usage()
while true do
local going = Outcome.match(kernel.beat(s, device), {
stopped = function(o)
if o.reason == "budget" then
failure = "max_iterations (" .. max_iter .. ") reached"
else
failure = "stopped: " .. tostring(o.reason)
end
return false
end,
error = function(o)
turns = turns + 1
usage = usage_of(s)
failure = error_text(o)
return false
end,
refused = function(o)
turns = turns + 1
usage = usage_of(s)
failure = refusal_text(o)
return false
end,
ok = function(o)
turns = turns + 1
usage = usage_of(s)
local answer = o.out
content = text_of(answer.content)
local calls = tool_use_blocks(answer.content)
if fire_on_turn(opts.on_turn, turns, answer, calls) == false then
return false
end
if #calls == 0 and answer.stop_reason ~= "pause_turn" then
return false
end
if limit ~= nil and usage.total_tokens >= limit then
failure = "token budget exceeded (" .. usage.total_tokens .. "/" .. limit .. ")"
return false
end
return true
end,
})
if not going then
break
end
end
local recorded, truncated = s:events()
if truncated then
local cut = "the session log is longer than one read of it ("
.. #recorded
.. " events, the kernel's row cap), so this run's history cannot be rebuilt whole"
return {
ok = false,
error = failure ~= nil and (failure .. "; " .. cut) or cut,
usage = usage,
num_turns = turns,
messages = {},
}
end
local messages = kernel.fold(recorded, device).messages
if failure ~= nil then
return { ok = false, error = failure, usage = usage, num_turns = turns, messages = messages }
end
return { ok = true, content = content, usage = usage, num_turns = turns, messages = messages }
end)
end
function M.run(opts)
return shape.assert_dev(M._run_impl(opts), RUN_RESULT, "agent.run result")
end
local function failed(err)
return { ok = false, error = err, usage = zero_usage(), num_turns = 0, messages = {} }
end
function M._run_impl(opts)
opts = opts or {}
if not opts.prompt or opts.prompt == "" then
return failed("prompt is required")
end
if opts.history ~= nil and type(opts.history) ~= "table" then
return failed("history must be a table (messages array)")
end
local provider = opts.provider or "anthropic"
if PORTS[provider] == nil then
return failed("unknown provider '" .. tostring(provider) .. "' (anthropic | openai)")
end
warn_anthropic_only(opts, provider)
local candidates = registry_candidates()
local connected = {}
if opts.mcp_servers and #opts.mcp_servers > 0 then
local bound, err, partial = connect_mcp_servers(opts.mcp_servers, opts)
if err then
disconnect_mcp_servers(partial)
return failed(err)
end
connected = partial
append_all(candidates, bound)
end
append_all(candidates, extra_candidates(opts.extra_tools))
local ran, result = pcall(run_loop, opts, provider, candidates, opts.max_iterations or 20)
disconnect_mcp_servers(connected)
if not ran then
return failed(tostring(result))
end
return result
end
M._build_tools = build_tools
M._registry_candidates = registry_candidates
M._extra_candidates = extra_candidates
M._resolve_mcp_group = resolve_mcp_group
M.shapes = {
usage = USAGE,
run_result = RUN_RESULT,
mcp_call_result = MCP_CALL_RESULT,
}
function M._test_helpers()
return {
map_finish_reason = proto_openai.map_finish_reason,
normalize_openai_response = proto_openai.parse,
convert_messages_to_openai = proto_openai.convert_messages,
tool_use_blocks = tool_use_blocks,
text_of = text_of,
}
end
return M