Spaces:
Running on Zero
Running on Zero
File size: 7,867 Bytes
98acb70 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | -- protocol.lua - Wire format between the loop and the model.
--
-- The whole "tool calling" abstraction is this file: a way to render tools as
-- text, and a way to read a tool call back out of text. No provider magic.
--
-- Two decisions here shape everything downstream:
--
-- 1. ANSWERING IS A TOOL CALL. There is no separate ANSWER verb; finishing is
-- {"tool":"answer","args":{"text":"..."}}. One grammar covers every turn,
-- the loop has one exit, and a constrained model can always terminate --
-- which it could not if answering lived outside the grammar.
--
-- 2. EVERY ARGUMENT IS A STRING on the wire. Tools declare number types and
-- the registry coerces on the way in, but the model only ever writes
-- quoted values. This removes an entire state from grammar.lua (numbers
-- are the fiddly case: they have no closing delimiter, so the machine
-- cannot tell "done" from "more digits" without lookahead).
local trace = require('trace')
local protocol = {}
-- Surface syntax. Line-delimited and JSON-shaped, because both properties
-- make grammar.lua's job tractable.
--
-- THOUGHT: <free text, one line>
-- CALL: {"tool":"<name>","args":{...}}
protocol.THOUGHT_PREFIX = "THOUGHT: "
protocol.CALL_PREFIX = "CALL: "
protocol.OBS_PREFIX = "RESULT: "
protocol.ANSWER_TOOL = "answer"
-- Where an unconstrained backend should stop generating.
protocol.STOP = { "\nRESULT:", "\nTHOUGHT:" }
-- The pseudo-tool that terminates the loop. Registered automatically by
-- tools.registry() so it appears in the catalogue and in the grammar enum.
protocol.answer_tool = {
name = protocol.ANSWER_TOOL,
description = "Give the final answer and stop. Use when the task is done.",
args = { { name = "text", type = "string", required = true } },
run = function(args) return args.text end,
}
-- Render one tool as the model sees it. This is prompt real estate charged
-- against the context budget on every single step, so it stays terse.
function protocol.render_tool(tool)
local parts = {}
for _, a in ipairs(tool.args or {}) do
parts[#parts + 1] = string.format('"%s":"<%s>"', a.name,
a.required and a.type or (a.type .. ", optional"))
end
return string.format(' %s -- %s\n {"tool":"%s","args":{%s}}',
tool.name, tool.description, tool.name, table.concat(parts, ","))
end
-- A catalogue terse enough for a tiny context window.
--
-- This is not a micro-optimization; it is forced. stories15M has seq_len=256,
-- and the verbose catalogue below costs 194 tokens for two tools and 262 for
-- four -- so with the default tool set the system prompt alone overflows the
-- model's entire context before the task is even stated.
--
-- Discovering that is one of the more useful things about driving a 15M
-- model: context budgeting stops being a hypothetical you handle at 100k
-- tokens and becomes the first thing that breaks. The compact form drops the
-- format tutorial (the grammar enforces the format anyway, so spending 90
-- tokens explaining it to a constrained model buys nothing) and renders each
-- tool as one line.
function protocol.render_compact(tools, task)
local lines = { "Tools:" }
for _, t in ipairs(tools:list()) do
local keys = {}
for _, a in ipairs(t.args or {}) do
if a.required then keys[#keys + 1] = '"' .. a.name .. '"' end
end
lines[#lines + 1] = string.format("%s{%s} %s",
t.name, table.concat(keys, ","), t.description)
end
lines[#lines + 1] = "TASK: " .. task
return table.concat(lines, "\n")
end
-- The system preamble: framing + tool catalogue + the task.
-- Kept separate from the transcript so context.lua can price it as a fixed
-- cost that is never evicted.
--
-- `compact` selects the terse catalogue above -- required for any model with
-- a small context window.
function protocol.render_system(tools, task, compact)
if compact then return protocol.render_compact(tools, task) end
local lines = {
"You are an agent. Work in steps. At each step write one thought and",
"then exactly one tool call, in this format:",
"",
protocol.THOUGHT_PREFIX .. "<why you are doing this>",
protocol.CALL_PREFIX .. '{"tool":"<name>","args":{...}}',
"",
"The result appears as " .. protocol.OBS_PREFIX .. "<output>, then you go again.",
"All argument values are strings. Available tools:",
"",
}
for _, t in ipairs(tools:list()) do
lines[#lines + 1] = protocol.render_tool(t)
end
lines[#lines + 1] = ""
lines[#lines + 1] = "TASK: " .. task
return table.concat(lines, "\n")
end
-- Render the running transcript: alternating model turns and observations.
function protocol.render_transcript(steps)
local out = {}
for _, s in ipairs(steps) do
if s.thought and s.thought ~= "" then
out[#out + 1] = protocol.THOUGHT_PREFIX .. s.thought
end
if s.call then
out[#out + 1] = protocol.CALL_PREFIX .. trace.encode({
tool = s.call.tool, args = s.call.args or {},
})
elseif s.raw then
out[#out + 1] = s.raw
end
if s.observation then
out[#out + 1] = protocol.OBS_PREFIX .. s.observation
end
if s.note then
out[#out + 1] = s.note
end
end
return table.concat(out, "\n")
end
-- Parse model output into a structured turn.
-- Returns one of:
-- { kind = "call", tool = , args = , thought = }
-- { kind = "answer", text = , thought = }
-- nil, err_message
--
-- err_message is written to be read BY THE MODEL -- it goes back as the next
-- observation, and is the entire mechanism of parse recovery. So it says what
-- was wrong and what correct output looks like, not "parse error at byte 34".
function protocol.parse(text)
if type(text) ~= "string" then
return nil, "No output was produced."
end
local thought = text:match(protocol.THOUGHT_PREFIX .. "([^\n]*)")
if thought then thought = thought:match("^%s*(.-)%s*$") end
-- Take the LAST call in the output. A model that restates the format from
-- the prompt before committing to a call is common; the last one is the
-- one it meant.
local payload
for m in text:gmatch(protocol.CALL_PREFIX .. "([^\n]*)") do payload = m end
if not payload then
return nil, string.format(
"No tool call found. Every step must contain a line starting with %q, "
.. "for example: %s{\"tool\":\"answer\",\"args\":{\"text\":\"...\"}}",
protocol.CALL_PREFIX, protocol.CALL_PREFIX)
end
payload = payload:match("^%s*(.-)%s*$")
local obj, err = trace.decode(payload)
if not obj then
return nil, string.format(
"The tool call was not valid JSON (%s). Write it on one line as "
.. '{"tool":"<name>","args":{"<key>":"<value>"}}.', err)
end
if type(obj) ~= "table" then
return nil, 'The tool call must be a JSON object starting with {"tool":.'
end
if type(obj.tool) ~= "string" then
return nil, 'The tool call is missing a "tool" name, e.g. {"tool":"calc","args":{...}}.'
end
if obj.args ~= nil and type(obj.args) ~= "table" then
return nil, '"args" must be a JSON object, e.g. "args":{"expr":"2+2"}.'
end
local args = obj.args or {}
if obj.tool == protocol.ANSWER_TOOL then
if type(args.text) ~= "string" then
return nil, 'The answer call needs a text argument: '
.. '{"tool":"answer","args":{"text":"<your answer>"}}.'
end
return { kind = "answer", text = args.text, thought = thought }
end
return { kind = "call", tool = obj.tool, args = args, thought = thought }
end
return protocol
|