context: tool turns + tool_calls on assistant; use_tool_role fallback
Phase 2 commit #3 per docs/PHASE2.md §12. Three concrete edits per §3 context.lua row (the BLOCKER-fold-in from review): (a) Loosen Context:append shape-per-role: assistant may carry empty content if tool_calls is non-empty; role:"tool" requires tool_call_id + content. (b) Preserve tool_calls / tool_call_id on store (Phase 1 :append built {role, content} only and silently dropped extras). (c) Extend to_messages() with two emission modes selected by use_tool_role: true (default) — OpenAI-standard role:"tool" + assistant turns with tool_calls (wrapped as {id, type:"function", function:{name, arguments}}). false (fallback) — collapse assistant-with-tool_calls + its following role:"tool" turns into a single assistant text turn with synthesized "[tool: name]\n<args>\n[result]\n <content>" body; merge consecutive assistant turns so the trailing post-tool-result text doesn't yield asst/asst back-to-back (same strict-template gotcha PHASE0.md §6 warned about for user/user). Alternation assert added (N4): role:"tool" turns must trace back through zero-or-more prior tool turns to an assistant-with-tool_calls. Catches sub-loop bugs at append time. Orphan tool turns rejected. pending_exec_output behavior unchanged per §3 row: buffer persists across tool-call sub-loops, flushes on next genuine user turn (B4). Smoke-tested §12 verify-row #3: (i) default mode round-trip — 5 OpenAI-shape messages, tool_calls + tool_call_id preserved. (ii) fallback mode round-trip — collapsed into 3 messages (system/user/assistant), tool_calls + role:"tool" not emitted. (iii) multi-call: 2 tool_calls in one assistant turn followed by 2 tool replies, both modes render correctly. (iv) orphan tool turn after user — assertion fires. (v) B4: pending_exec_output survives a tool sub-loop, flushes on next :append_user. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+141
-7
@@ -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
|
||||
-- <args>\n[result]\n<content>" 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
|
||||
|
||||
Reference in New Issue
Block a user