local M = {}
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 function llm_call(messages, opts)
local api_key = std.env.get("ANTHROPIC_API_KEY")
if not api_key then
return nil, "ANTHROPIC_API_KEY not set"
end
local model = opts.model or std.env.get_or("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001")
local body = {
model = model,
max_tokens = opts.max_tokens or 4096,
messages = messages,
}
if opts.system and opts.system ~= "" then
body.system = opts.system
end
if opts.tools and #opts.tools > 0 then
body.tools = opts.tools
end
local headers = {
["x-api-key"] = api_key,
["anthropic-version"] = "2023-06-01",
["content-type"] = "application/json",
}
if opts.context_management ~= nil then
headers["anthropic-beta"] = "context-management-2025-06-27"
body.context_management = opts.context_management
end
local resp = http.request("https://api.anthropic.com/v1/messages", {
method = "POST",
headers = headers,
body = std.json.encode(body),
timeout = opts.timeout or 120,
})
if resp.status ~= 200 then
return nil, "API error " .. resp.status .. ": " .. resp.body
end
local decoded = std.json.decode(resp.body)
return decoded, nil
end
local function new_budget_tracker(max_tokens_budget)
local tracker = {
input_tokens = 0,
output_tokens = 0,
total_tokens = 0,
limit = max_tokens_budget,
}
function tracker:add(usage)
if usage then
self.input_tokens = self.input_tokens + (usage.input_tokens or 0)
self.output_tokens = self.output_tokens + (usage.output_tokens or 0)
self.total_tokens = self.input_tokens + self.output_tokens
end
end
function tracker:exceeded()
if not self.limit then return false end
return self.total_tokens >= self.limit
end
function tracker:summary()
return {
input_tokens = self.input_tokens,
output_tokens = self.output_tokens,
total_tokens = self.total_tokens,
}
end
return tracker
end
local function connect_mcp_servers(servers)
local mcp_tool_map = {}
local connected = {}
for _, srv in ipairs(servers) do
local name = srv.name
local command = srv.command
local args = srv.args or {}
local ok, err = pcall(mcp.connect, name, command, args)
if not ok then
return nil, "mcp connect failed for '" .. name .. "': " .. tostring(err), connected
end
table.insert(connected, name)
local list_result = mcp.list_tools(name)
if not list_result.ok then
return nil, "mcp list_tools failed for '" .. name .. "': " .. tostring(list_result.error), connected
end
local tools = list_result.tools or {}
for _, t in ipairs(tools) do
local ns_name = name .. "__" .. t.name
local input_schema = t.inputSchema or t.input_schema or { type = "object", properties = {} }
mcp_tool_map[ns_name] = {
server = name,
tool = t.name,
def = {
name = ns_name,
description = t.description or "",
input_schema = input_schema,
},
}
end
end
return mcp_tool_map, 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 function build_tools(mcp_tool_map, extra_tools)
local tools = {}
local lua_tools = tool.schema()
for _, t in ipairs(lua_tools) do
table.insert(tools, t)
end
for _, entry in pairs(mcp_tool_map) do
table.insert(tools, entry.def)
end
if extra_tools then
for _, t in ipairs(extra_tools) do
table.insert(tools, t)
end
end
return tools
end
local function dispatch_tool(name, input, mcp_tool_map)
if mcp_tool_map[name] then
local entry = mcp_tool_map[name]
local call_result = mcp.call(entry.server, entry.tool, input)
if not call_result.ok then
return tostring(call_result.error or "mcp.call failed"), true
end
local is_error = call_result.is_error == true
if is_error then
log.warn(string.format("mcp tool '%s.%s' returned isError=true", entry.server, entry.tool))
end
local content_blocks = call_result.content or {}
if #content_blocks == 1 and content_blocks[1].type == "text" then
return content_blocks[1].text, is_error
elseif #content_blocks == 0 then
return "", is_error
else
return std.json.encode(content_blocks), is_error
end
end
local ok, res = pcall(tool.call, name, input)
if not ok then
return "tool error: " .. tostring(res), true
end
if type(res) == "table" then
return std.json.encode(res), false
end
return tostring(res), false
end
local function extract_text(content)
local parts = {}
for _, block in ipairs(content or {}) do
if block.type == "text" and block.text then
table.insert(parts, block.text)
end
end
return table.concat(parts, "\n")
end
function M.run(opts)
opts = opts or {}
if not opts.prompt or opts.prompt == "" then
return { ok = false, error = "prompt is required", usage = { input_tokens = 0, output_tokens = 0, total_tokens = 0 }, num_turns = 0, messages = {} }
end
local budget = new_budget_tracker(opts.max_tokens_budget)
local max_iter = opts.max_iterations or 20
local mcp_tool_map = {}
local connected_servers = {}
if opts.mcp_servers and #opts.mcp_servers > 0 then
local tool_map, err, partial_connected = connect_mcp_servers(opts.mcp_servers)
if err then
disconnect_mcp_servers(partial_connected)
return {
ok = false,
error = err,
usage = budget:summary(),
num_turns = 0,
messages = {},
}
end
mcp_tool_map = tool_map
connected_servers = partial_connected
end
local tools = build_tools(mcp_tool_map, opts.extra_tools)
local cm_final
if opts.context_management == false then
cm_final = nil
else
cm_final = opts.context_management_config or DEFAULT_CONTEXT_MANAGEMENT
end
local call_opts = {
model = opts.model,
max_tokens = opts.max_tokens or 4096,
timeout = opts.timeout or 120,
system = opts.system,
tools = tools,
context_management = cm_final, }
local messages = {
{ role = "user", content = opts.prompt },
}
local num_turns = 0
local final_content = ""
local loop_error = nil
local loop_ok, loop_err = pcall(function()
local iter = 0
while true do
local response, api_err = llm_call(messages, call_opts)
if not response then
loop_error = api_err
return
end
table.insert(messages, {
role = "assistant",
content = response.content,
})
budget:add(response.usage)
num_turns = num_turns + 1
local tool_calls = {}
for _, block in ipairs(response.content or {}) do
if block.type == "tool_use" then
table.insert(tool_calls, block)
end
end
final_content = extract_text(response.content)
if opts.on_turn then
local cb_ok, cb_err = pcall(opts.on_turn, {
turn_number = num_turns,
content = response.content,
tool_calls = tool_calls,
usage = response.usage,
context_management = response.context_management,
})
if not cb_ok then
log.warn("agent: on_turn callback error: " .. tostring(cb_err))
end
end
if #tool_calls == 0 then
break
end
local stop_reason = response.stop_reason
if stop_reason == "end_turn" or stop_reason == "max_tokens" then
break
end
iter = iter + 1
if iter >= max_iter then
log.warn("agent: max iterations (" .. max_iter .. ") reached")
break
end
if budget:exceeded() then
log.warn("agent: token budget exceeded (" .. budget.total_tokens .. "/" .. budget.limit .. ")")
break
end
local tool_results = {}
for _, tc in ipairs(tool_calls) do
local content_str, is_error = dispatch_tool(tc.name, tc.input, mcp_tool_map)
table.insert(tool_results, {
type = "tool_result",
tool_use_id = tc.id,
content = content_str,
is_error = is_error or nil,
})
end
table.insert(messages, {
role = "user",
content = tool_results,
})
end
end)
disconnect_mcp_servers(connected_servers)
if not loop_ok then
return {
ok = false,
error = tostring(loop_err),
usage = budget:summary(),
num_turns = num_turns,
messages = messages,
}
end
if loop_error then
return {
ok = false,
error = loop_error,
usage = budget:summary(),
num_turns = num_turns,
messages = messages,
}
end
return {
ok = true,
content = final_content,
usage = budget:summary(),
num_turns = num_turns,
messages = messages,
}
end
return M