feat(v1): stateless tool continuation + sampling-param passthrough

- Stateless tool continuation. OpenAI-compatible clients (opencode, etc.)
  resend the full messages array — system, user, assistant(tool_calls),
  tool(results) — but no conversation_id, so the prior
  "conversation_id required for tool continuation" 400 broke every tool call.
  When no conversation_id is present, rebuild the agent + pending tool calls +
  tool results directly from the resent messages
  (StreamProcessor.build_continuation_from_messages) instead of loading
  server-side pending_tool_state, and call gen_continuation.

- Forward OpenAI sampling params (temperature, max_tokens,
  max_completion_tokens, top_p, frequency_penalty, presence_penalty, stop,
  seed) from the request to the LLM gen call; the agent otherwise uses its
  configured defaults.
This commit is contained in:
Alex committed 2026-06-04 12:17:27 +01:00
1 parent b1f220ec05
commit 534ffc4e5b
4 files changed
+107 -14

No files matched your search

@@ -172,6 +172,63 @@ class StreamProcessor:
tools_data=tools_data,
)
def build_continuation_from_messages(self, messages, tool_actions):
"""Rebuild a tool continuation from the request messages (STATELESS).
OpenAI-compatible clients (opencode, etc.) resend the full conversation
-- system, user, assistant(tool_calls), tool(results) -- but carry no
conversation_id, so there is no server-side ``pending_tool_state`` to
load. Reconstruct the agent + continuation context directly from the
resent messages and return the same tuple as ``resume_from_tool_actions``:
(agent, messages, tools_dict, pending_tool_calls, tool_actions,
reasoning_content).
"""
# Locate the last assistant message that issued tool calls.
pending_idx = None
for i in range(len(messages) - 1, -1, -1):
m = messages[i]
if m.get("role") == "assistant" and m.get("tool_calls"):
pending_idx = i
break
if pending_idx is None:
raise ValueError(
"No assistant message with tool_calls found for continuation"
)
pending_tool_calls = []
for tc in messages[pending_idx].get("tool_calls") or []:
fn = tc.get("function") or {}
raw_args = fn.get("arguments")
try:
args = (
json.loads(raw_args)
if isinstance(raw_args, str)
else (raw_args or {})
)
except (json.JSONDecodeError, TypeError):
args = {}
name = fn.get("name", "")
pending_tool_calls.append(
{
"call_id": tc.get("id", ""),
"name": name,
"tool_name": name,
"action_name": name,
"llm_name": name,
"arguments": args,
}
)
# The conversation up to (but not including) the assistant tool_calls;
# gen_continuation re-appends the assistant message + tool results.
prior_messages = [dict(m) for m in messages[:pending_idx]]
# Build a normal agent (config / LLM / client tools), no new question.
agent = self.build_agent("")
tools_dict = agent.tool_executor.get_tools()
return agent, prior_messages, tools_dict, pending_tool_calls, tool_actions, ""
def _load_conversation_history(self):
"""Load conversation history either from DB or request"""
if self.conversation_id and self.initial_user_id:
@@ -1268,6 +1325,7 @@ class StreamProcessor:
"json_schema": self.agent_config.get("json_schema"),
"json_schema_strict": self.agent_config.get("json_schema_strict", True),
"json_object": self.agent_config.get("json_object", False),
"llm_params": self.data.get("llm_params") or {},
"compressed_summary": self.compressed_summary,
"llm": llm,
"llm_handler": llm_handler,