diff --git a/context.lua b/context.lua index e52fdbd..4ef080c 100644 --- a/context.lua +++ b/context.lua @@ -1,7 +1,10 @@ -- context.lua — in-memory conversation history + token budget. -- Phase 0: ordered turn list, sliding-window eviction by max_turns. -- Tokenization is char/4 heuristic in Phase 0; accurate count is Phase 3 (Q1). --- See docs/PHASE0.md §6, §8. +-- Phase 2 (added 2026-05-12): support for `role:"tool"` turns and assistant +-- turns carrying `tool_calls = [...]`, plus a `use_tool_role` rendering +-- toggle for the strict-chat-template fallback path (Q18). +-- See docs/PHASE0.md §6, §8 and docs/PHASE2.md §3 / §5. local M = {} @@ -25,13 +28,53 @@ function M.new(opts) pending_exec_output = nil, -- buffered until next user turn (§6) max_turns = opts.max_turns or 40, token_budget = opts.token_budget or 4096, + -- Phase 2: tool-role rendering toggle. true = emit OpenAI-standard + -- role:"tool" messages from to_messages(); false = collapse + -- assistant+tool_calls and tool turns into a single assistant text + -- turn for chat templates that reject the role:"tool" shape. + -- Default true per PHASE2.md §12 "Q18 default"; flip from caller. + use_tool_role = (opts.use_tool_role == nil) and true + or opts.use_tool_role, }, Context) end +-- Append a turn. Phase 2 widens what's valid: +-- role="user" content (string) required +-- role="system" content (string) required (callers shouldn't add system +-- turns directly; system prompt is stored separately and +-- prepended at to_messages time per §6) +-- role="assistant" content may be empty IF tool_calls is non-empty; +-- otherwise content required +-- role="tool" tool_call_id required + content required; the preceding +-- stored turn must be an assistant turn with non-empty +-- tool_calls (debug assertion catches sub-loop bugs early +-- per PHASE2.md §3 row + N4 in review) function Context:append(turn) - assert(type(turn) == "table" and turn.role and turn.content, - "context:append requires { role = ..., content = ... }") - self.turns[#self.turns + 1] = { role = turn.role, content = turn.content } + assert(type(turn) == "table" and turn.role, + "context:append requires { role = ... }") + local stored = { role = turn.role, content = turn.content or "" } + if turn.role == "assistant" and turn.tool_calls and #turn.tool_calls > 0 then + stored.tool_calls = turn.tool_calls + elseif turn.role == "tool" then + assert(turn.tool_call_id, "context:append role=tool requires tool_call_id") + assert(turn.content, "context:append role=tool requires content") + -- A tool turn may follow either an assistant-with-tool_calls (the + -- first reply in the sub-loop) or another tool turn (subsequent + -- replies when the assistant emitted multiple parallel tool_calls). + -- Walk back through tool turns until we hit a non-tool; that turn + -- must be an assistant with non-empty tool_calls. + local j = #self.turns + while j > 0 and self.turns[j].role == "tool" do j = j - 1 end + local anchor = self.turns[j] + assert(anchor and anchor.role == "assistant" + and anchor.tool_calls and #anchor.tool_calls > 0, + "context:append role=tool must follow assistant with tool_calls " + .. "(possibly via prior tool turns in the same sub-loop)") + stored.tool_call_id = turn.tool_call_id + else + assert(turn.content, "context:append requires content for role=" .. turn.role) + end + self.turns[#self.turns + 1] = stored end -- Buffer captured shell-exec output. Per §6 (post user-test fix), exec output @@ -58,12 +101,103 @@ function Context:append_user(content) self:append({ role = "user", content = content }) end +-- Compact JSON-ish rendering used by the fallback (use_tool_role=false) path +-- to convert a tool_calls + tool-result pair into inline text. Not OpenAI- +-- standard — only used when a strict chat template rejects role:"tool". +local function inline_tool_call(call, result_content) + return ("[tool: %s]\n%s\n[result]\n%s") + :format(call.name or "?", + tostring(call.arguments or ""), + tostring(result_content or "")) +end + -- Render the messages array for broker.chat (system prompt prepended; turns --- in order). The system prompt is NOT stored in self.turns per §6. +-- in order). Phase 2 adds two emission modes: +-- +-- use_tool_role = true (default): pass through OpenAI-standard +-- {role:"assistant", content, tool_calls} and {role:"tool", tool_call_id, +-- content} turns unchanged. +-- +-- use_tool_role = false (fallback, Q18): collapse each +-- assistant-with-tool_calls + its following role:"tool" turn(s) into a +-- single assistant text turn carrying the synthesized "[tool: name]\n +-- \n[result]\n" body. The role:"tool" turns and the +-- tool_calls field are NOT emitted. Same logical alternation seen by the +-- model (user → assistant → user → assistant), no strict-template breakage. +-- +-- The system prompt is NOT stored in self.turns per §6. function Context:to_messages() local msgs = { { role = "system", content = self.system_prompt } } - for _, t in ipairs(self.turns) do - msgs[#msgs + 1] = { role = t.role, content = t.content } + + if self.use_tool_role then + for _, t in ipairs(self.turns) do + local m = { role = t.role, content = t.content } + if t.role == "assistant" and t.tool_calls then + -- OpenAI shape wraps each call as + -- {id, type:"function", function:{name, arguments}}. + local oai = {} + for i, c in ipairs(t.tool_calls) do + oai[i] = { + id = c.id, + type = "function", + ["function"] = { name = c.name, + arguments = c.arguments or "" }, + } + end + m.tool_calls = oai + elseif t.role == "tool" then + m.tool_call_id = t.tool_call_id + end + msgs[#msgs + 1] = m + end + return msgs + end + + -- Fallback path: walk turns, collapse asst-with-tool_calls + following + -- tool turns into a single asst text turn. Merge consecutive assistant + -- turns afterward so the trailing post-tool-result assistant text + -- doesn't produce asst/asst back-to-back (which strict templates would + -- also reject — same gotcha PHASE0.md §6 warned about for user/user). + local function push_or_merge_assistant(content) + local last = msgs[#msgs] + if last and last.role == "assistant" then + last.content = last.content .. "\n" .. content + else + msgs[#msgs + 1] = { role = "assistant", content = content } + end + end + + local i = 1 + while i <= #self.turns do + local t = self.turns[i] + if t.role == "assistant" and t.tool_calls then + local parts = {} + if t.content and t.content ~= "" then + parts[#parts + 1] = t.content + end + for ci, call in ipairs(t.tool_calls) do + local result_text = "" + local next_t = self.turns[i + ci] + if next_t and next_t.role == "tool" + and next_t.tool_call_id == call.id then + result_text = next_t.content + end + parts[#parts + 1] = inline_tool_call(call, result_text) + end + push_or_merge_assistant(table.concat(parts, "\n")) + i = i + 1 + #t.tool_calls + elseif t.role == "tool" then + -- Orphan tool turn (no preceding asst-tool_calls captured it). + -- Shouldn't happen given the :append assertion, but defensively + -- drop it rather than emit a malformed message. + i = i + 1 + elseif t.role == "assistant" then + push_or_merge_assistant(t.content or "") + i = i + 1 + else + msgs[#msgs + 1] = { role = t.role, content = t.content } + i = i + 1 + end end return msgs end