Skip to content

fix(gateway): preserve tool_call_id in api_server conversation history#17422

Open
inkccc wants to merge 1 commit into
NousResearch:mainfrom
inkccc:fix/api-server-preserve-tool-call-id
Open

fix(gateway): preserve tool_call_id in api_server conversation history#17422
inkccc wants to merge 1 commit into
NousResearch:mainfrom
inkccc:fix/api-server-preserve-tool-call-id

Conversation

@inkccc
Copy link
Copy Markdown

@inkccc inkccc commented Apr 29, 2026

What does this PR do?

Fix a bug where api_server.py strips tool_call_id, tool_calls, and name fields when reconstructing conversation history. This causes strict providers (xiaomimimo, stepfun) to return HTTP 400 "tool_call_id is required" for API clients that send conversation history with tool messages.

Related Issue

Fixes #16800

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • gateway/platforms/api_server.py: Preserve tool_call_id, tool_calls, and name fields in conversation history reconstruction at three locations (lines 1747, 2409, 2435)

Root Cause

The API server's /v1/runs and /v1/responses handlers reconstruct conversation history by only keeping role and content fields, discarding tool_call_id, tool_calls, and name. When API clients (including third-party WebUIs like hermes-web-ui) send follow-up messages with full conversation history, strict providers reject the request because tool messages lack the required tool_call_id field.

The gateway's run.py already has correct logic to preserve these fields (lines 10737-10743), but api_server.py was missing this handling when rebuilding conversation history from client requests.

Impact

  • Affects all API clients that send conversation_history via /v1/runs or /v1/responses
  • Does NOT affect CLI sessions (they use a different code path in gateway/run.py)
  • Strict providers (xiaomimimo, stepfun) will reject requests with HTTP 400
  • Tolerant providers (deepseek) may work but with degraded functionality

Evidence

Error Log

2026-04-29 18:36:08,891 ERROR [eph_mojx6dbl_itkoil] root: Non-retryable client error: 
Error code: 400 - {'error': {'code': '400', 'message': 'Param Incorrect', 
'param': '`tool_call_id` is not set', 'type': ''}}

Session Analysis

  • Failed session: eph_mojx6dbl_itkoil (platform=api_server) — 7/7 tool messages missing tool_call_id
  • Success session: 20260429_183619_6f3307 (platform=cli) — 0/13 tool messages missing

Comparison

Session Platform tool_call_id Result
eph_mojx6dbl_itkoil api_server All missing ❌ 400 error
20260429_183619_6f3307 cli All present ✅ Success

Both sessions use the same provider (mimo-v2.5-pro), confirming this is an API server issue, not a provider issue.

How to Test

  1. Start Hermes gateway
  2. Use an API client (e.g., hermes-web-ui) to create a conversation with tool calls
  3. Send a follow-up message in the same conversation
  4. Verify no "tool_call_id is required" error
  5. Check that CLI sessions still work normally

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass (no dedicated api_server tests exist)
  • I've added tests for my changes

Documentation & Housekeeping

  • I've updated relevant documentation — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture — or N/A
  • I've considered cross-platform impact — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

The API server was stripping tool_call_id, tool_calls, and name fields
when reconstructing conversation history. This caused strict providers
(xiaomimimo, stepfun) to return HTTP 400 'tool_call_id is required' for
WebUI sessions with tool calls.

Fix by preserving all message fields in the three conversation_history.append
locations (lines 1747, 2409, 2435).

Evidence:
- Error log: 'tool_call_id is not set' in ~/.hermes/logs/errors.log
- Session analysis: 7/7 tool messages missing tool_call_id in WebUI session
- Comparison: CLI sessions work (platform=cli), WebUI sessions fail (platform=api_server)
- Both use same provider (mimo-v2.5-pro), confirming API server issue

Fixes NousResearch#16800
Copilot AI review requested due to automatic review settings April 29, 2026 11:50
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes API server conversation history reconstruction so tool-related fields aren’t dropped, preventing strict providers from rejecting follow-up WebUI requests that include prior tool turns.

Changes:

  • Preserve tool_call_id, tool_calls, and name when rebuilding conversation_history for /v1/responses.
  • Preserve the same fields for /v1/runs, including the “input-as-history” fallback path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1749 to +1754
if entry.get("tool_call_id"):
msg["tool_call_id"] = entry["tool_call_id"]
# Preserve tool_calls for assistant messages
if entry.get("tool_calls"):
msg["tool_calls"] = entry["tool_calls"]
if entry.get("name"):
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that conversation_history can pass through tool-related fields, these values should be type-validated (e.g., tool_call_id/name as strings, tool_calls as a list) before forwarding to the agent. Without validation, a malformed client payload (tool_calls as dict/string) can lead to incorrect downstream behavior or server errors; consider returning 400 when the optional fields are present but of the wrong type. Also, if the intent is to preserve fields, checking key presence (vs truthiness) avoids silently dropping empty values supplied by the client.

Suggested change
if entry.get("tool_call_id"):
msg["tool_call_id"] = entry["tool_call_id"]
# Preserve tool_calls for assistant messages
if entry.get("tool_calls"):
msg["tool_calls"] = entry["tool_calls"]
if entry.get("name"):
if "tool_call_id" in entry:
if not isinstance(entry["tool_call_id"], str):
return web.json_response(
_openai_error(f"conversation_history[{i}].tool_call_id must be a string"),
status=400,
)
msg["tool_call_id"] = entry["tool_call_id"]
# Preserve tool_calls for assistant messages
if "tool_calls" in entry:
if not isinstance(entry["tool_calls"], list):
return web.json_response(
_openai_error(f"conversation_history[{i}].tool_calls must be an array"),
status=400,
)
msg["tool_calls"] = entry["tool_calls"]
if "name" in entry:
if not isinstance(entry["name"], str):
return web.json_response(
_openai_error(f"conversation_history[{i}].name must be a string"),
status=400,
)

Copilot uses AI. Check for mistakes.
Comment on lines +2418 to +2427
msg = {"role": str(entry["role"]), "content": str(entry["content"])}
# Preserve tool_call_id for tool messages — required by strict providers
if entry.get("tool_call_id"):
msg["tool_call_id"] = entry["tool_call_id"]
# Preserve tool_calls for assistant messages
if entry.get("tool_calls"):
msg["tool_calls"] = entry["tool_calls"]
if entry.get("name"):
msg["name"] = entry["name"]
conversation_history.append(msg)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this endpoint, conversation_history is declared as List[Dict[str, str]], but the newly preserved fields (e.g., tool_calls as list/dict, tool_call_id/name) make the dict values non-string. Updating the type annotation to Dict[str, Any] (and ideally validating the optional fields) will prevent misleading typing and make it clearer what shapes agent.run_conversation can receive here.

Copilot uses AI. Check for mistakes.
Comment on lines +2453 to +2462
msg_out = {"role": msg["role"], "content": str(content)}
# Preserve tool_call_id for tool messages — required by strict providers
if msg.get("tool_call_id"):
msg_out["tool_call_id"] = msg["tool_call_id"]
# Preserve tool_calls for assistant messages
if msg.get("tool_calls"):
msg_out["tool_calls"] = msg["tool_calls"]
if msg.get("name"):
msg_out["name"] = msg["name"]
conversation_history.append(msg_out)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The raw_input multi-message fallback only includes prior messages when msg.get("content") is truthy, which will drop common assistant tool-call messages that have content=None/missing but do have tool_calls. That means even with the new preservation logic, tool_calls-only assistant messages won’t make it into conversation_history, potentially leaving subsequent tool results orphaned (and still triggering strict-provider validation errors). Consider relaxing the guard to accept messages with role plus (content OR tool_calls OR tool_call_id) and then normalizing content to "" when absent.

Copilot uses AI. Check for mistakes.
Comment on lines +1747 to +1756
msg = {"role": str(entry["role"]), "content": entry_content}
# Preserve tool_call_id for tool messages — required by strict providers
if entry.get("tool_call_id"):
msg["tool_call_id"] = entry["tool_call_id"]
# Preserve tool_calls for assistant messages
if entry.get("tool_calls"):
msg["tool_calls"] = entry["tool_calls"]
if entry.get("name"):
msg["name"] = entry["name"]
conversation_history.append(msg)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a regression test that posts conversation_history containing (1) an assistant message with tool_calls (often content=None/missing) and (2) a tool message with tool_call_id, and assert the adapter forwards both fields unchanged. This would have caught the original stripping bug and will prevent future regressions across both /v1/responses and /v1/runs paths.

Copilot uses AI. Check for mistakes.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery labels Apr 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants