feat(llm): handle LLM tool call responses with execution loop #137

Merged
fuzzy merged 4 commits from fix/0095-handle-tool-calls into main 2026-07-05 17:40:44 +00:00
Owner

What

Handle LLM tool call responses: detect tool_calls in responses, execute tools via MCP servers, send results back to the LLM, and return the final text response.

Why

Previously, tool call responses with null content would return the string "None". Now tool calls are properly executed in a multi-turn loop.

Changes

  • _extract_text handles None content gracefully
  • complete() accepts execute_tool callback for tool call loop
  • MCPServer.call_tool() for executing MCP tool calls
  • Engine wires MCP servers to LLM tool execution

Testing

  • 145 unit tests pass
  • Pre-commit hooks pass

Closes #95

## What Handle LLM tool call responses: detect `tool_calls` in responses, execute tools via MCP servers, send results back to the LLM, and return the final text response. ## Why Previously, tool call responses with null `content` would return the string `"None"`. Now tool calls are properly executed in a multi-turn loop. ## Changes - `_extract_text` handles `None` content gracefully - `complete()` accepts `execute_tool` callback for tool call loop - `MCPServer.call_tool()` for executing MCP tool calls - Engine wires MCP servers to LLM tool execution ## Testing - [x] 145 unit tests pass - [x] Pre-commit hooks pass Closes #95
feat(llm): handle LLM tool call responses with execution loop
Some checks failed
CI / test (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
74f2ce9416
- _extract_text handles None content gracefully
- complete() accepts execute_tool callback for tool call loop
- MCPServer.call_tool() for executing MCP tool calls
- Engine wires MCP servers to LLM tool execution
Closes #95
fuzzy force-pushed fix/0095-handle-tool-calls from 74f2ce9416
Some checks failed
CI / test (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
to 2e4c8cd328
Some checks failed
CI / test (pull_request) Successful in 50s
CI / lint (pull_request) Failing after 1m17s
2026-07-05 16:52:07 +00:00
Compare
fuzzy force-pushed fix/0095-handle-tool-calls from 2e4c8cd328
Some checks failed
CI / test (pull_request) Successful in 50s
CI / lint (pull_request) Failing after 1m17s
to d0b8e3324d
Some checks failed
CI / test (pull_request) Successful in 50s
CI / lint (pull_request) Failing after 1m17s
2026-07-05 16:55:45 +00:00
Compare
fuzzy force-pushed fix/0095-handle-tool-calls from d0b8e3324d
Some checks failed
CI / test (pull_request) Successful in 50s
CI / lint (pull_request) Failing after 1m17s
to 649de4703b
Some checks failed
CI / test (pull_request) Successful in 51s
CI / lint (pull_request) Failing after 1m19s
2026-07-05 17:00:36 +00:00
Compare
fuzzy self-assigned this 2026-07-05 17:01:12 +00:00
fuzzy force-pushed fix/0095-handle-tool-calls from 649de4703b
Some checks failed
CI / test (pull_request) Successful in 51s
CI / lint (pull_request) Failing after 1m19s
to ab0ad4ff7c
Some checks failed
CI / lint (pull_request) Failing after 1m17s
CI / test (pull_request) Successful in 49s
2026-07-05 17:05:10 +00:00
Compare
fuzzy force-pushed fix/0095-handle-tool-calls from ab0ad4ff7c
Some checks failed
CI / lint (pull_request) Failing after 1m17s
CI / test (pull_request) Successful in 49s
to e3a1f1dae8
Some checks failed
CI / test (pull_request) Successful in 48s
CI / lint (pull_request) Failing after 1m24s
2026-07-05 17:13:19 +00:00
Compare
fuzzy force-pushed fix/0095-handle-tool-calls from e3a1f1dae8
Some checks failed
CI / test (pull_request) Successful in 48s
CI / lint (pull_request) Failing after 1m24s
to 55622a07d0
Some checks failed
CI / test (pull_request) Successful in 46s
CI / lint (pull_request) Failing after 1m18s
2026-07-05 17:19:58 +00:00
Compare
fuzzy force-pushed fix/0095-handle-tool-calls from 55622a07d0
Some checks failed
CI / test (pull_request) Successful in 46s
CI / lint (pull_request) Failing after 1m18s
to bae0bd821b
Some checks failed
CI / test (pull_request) Successful in 52s
CI / lint (pull_request) Failing after 1m20s
2026-07-05 17:24:18 +00:00
Compare
fuzzy force-pushed fix/0095-handle-tool-calls from bae0bd821b
Some checks failed
CI / test (pull_request) Successful in 52s
CI / lint (pull_request) Failing after 1m20s
to 8e165c5fc5
Some checks failed
CI / test (pull_request) Successful in 49s
CI / lint (pull_request) Failing after 1m24s
2026-07-05 17:28:18 +00:00
Compare
fuzzy force-pushed fix/0095-handle-tool-calls from 8e165c5fc5
Some checks failed
CI / test (pull_request) Successful in 49s
CI / lint (pull_request) Failing after 1m24s
to 5babba5057
Some checks failed
CI / test (pull_request) Successful in 47s
CI / lint (pull_request) Failing after 1m21s
2026-07-05 17:30:46 +00:00
Compare
refactor(engine): extract _init_mcp_servers to reduce run_job complexity
All checks were successful
CI / test (pull_request) Successful in 53s
CI / lint (pull_request) Successful in 1m19s
bafb3a353c
C901 cyclomatic complexity was 12. Extracting the MCP server
connection and tool collection logic brings it under the threshold.
Closes #137
the.auditor requested changes 2026-07-05 17:34:19 +00:00
Dismissed
the.auditor left a comment

Automated Review — PR #137

Result: REQUEST_CHANGES


This is a substantial and important PR — the tool call execution loop enables proper multi-turn LLM conversations with MCP tool integration. The overall design is sound, but there is one blocking issue.

Good parts

  • _send_and_parse() cleanly separates HTTP from response parsing
  • _extract_text() handles None content gracefully (fixes the "None" string bug)
  • MCPServer.call_tool() follows the existing _send_request pattern
  • _init_mcp_servers() refactor is a nice cleanup
  • Tool call loop has a safety limit (10 rounds) and proper message structure

Blocking Issue

  1. src/kronai/engine.py:86-92 — MCPError during tool execution leaks server connections.

The _execute_tool closure calls server.call_tool() which can raise MCPError. This propagates through complete() and out, but run_job() only catches LLMError:

try:
    response = llm_client.complete(..., execute_tool=_execute_tool)
except LLMError:
    ...
    _cleanup_mcps(list(mcps.values()))
    return False

If the MCP server fails mid-tool-call, MCPError bypasses the cleanup and status/notification calls. The subprocess is leaked.

Options to fix:

  • Option A: Have _execute_tool catch MCPError and raise LLMError instead (or return an error string)
  • Option B: Add except (LLMError, MCPError, JobError) to the outer handler
  • Option C: Have complete() accept a broader exception type or wrap tool execution errors

Non-blocking suggestions

  1. src/kronai/engine.py:90str(server.call_tool(...))call_tool already returns str. Redundant str() call is harmless but unnecessary.

  2. src/kronai/llm.py:96max_tool_rounds = 10 is hardcoded. Consider making it a constructor parameter (like max_retries and base_delay in PR #130).

## Automated Review — PR #137 **Result: REQUEST_CHANGES** --- This is a substantial and important PR — the tool call execution loop enables proper multi-turn LLM conversations with MCP tool integration. The overall design is sound, but there is one blocking issue. ### Good parts - `_send_and_parse()` cleanly separates HTTP from response parsing - `_extract_text()` handles None content gracefully (fixes the `"None"` string bug) - `MCPServer.call_tool()` follows the existing `_send_request` pattern - `_init_mcp_servers()` refactor is a nice cleanup - Tool call loop has a safety limit (10 rounds) and proper message structure ### Blocking Issue 1. **`src/kronai/engine.py:86-92`** — MCPError during tool execution leaks server connections. The `_execute_tool` closure calls `server.call_tool()` which can raise `MCPError`. This propagates through `complete()` and out, but `run_job()` only catches `LLMError`: ```python try: response = llm_client.complete(..., execute_tool=_execute_tool) except LLMError: ... _cleanup_mcps(list(mcps.values())) return False ``` If the MCP server fails mid-tool-call, `MCPError` bypasses the cleanup and status/notification calls. The subprocess is leaked. **Options to fix:** - **Option A:** Have `_execute_tool` catch `MCPError` and raise `LLMError` instead (or return an error string) - **Option B:** Add `except (LLMError, MCPError, JobError)` to the outer handler - **Option C:** Have `complete()` accept a broader exception type or wrap tool execution errors ### Non-blocking suggestions 2. **`src/kronai/engine.py:90`** — `str(server.call_tool(...))` — `call_tool` already returns `str`. Redundant `str()` call is harmless but unnecessary. 3. **`src/kronai/llm.py:96`** — `max_tool_rounds = 10` is hardcoded. Consider making it a constructor parameter (like `max_retries` and `base_delay` in PR #130).
fix: address PR review - remove redundant str(), make max_tool_rounds configurable
Some checks failed
CI / test (pull_request) Successful in 48s
CI / lint (pull_request) Has been cancelled
d1ce3428ab
- Remove redundant str() wrapping around call_tool return (#146)
- Make max_tool_rounds a constructor parameter on LLMClient (#147)
fix(engine): catch MCPError in _execute_tool to prevent connection leaks
All checks were successful
CI / test (pull_request) Successful in 46s
CI / lint (pull_request) Successful in 1m14s
bdb2476204
Tool execution failures now raise LLMError, ensuring the existing
error handler in run_job cleans up MCP connections and saves
status.
Addresses PR #137 review: blocking issue
the.auditor left a comment

Automated Review — PR #137 (re-review)

Result: APPROVED


Blocking issue resolved. _execute_tool now wraps MCPError in LLMError, so tool execution failures are caught by the existing cleanup/status/notification handler in run_job(). Server connections are no longer leaked.

Additionally, max_tool_rounds is now a constructor parameter matching the pattern from max_retries/base_delay. Redundant str() tracked in #146.

No remaining blocking issues.

## Automated Review — PR #137 (re-review) **Result: APPROVED** --- Blocking issue resolved. `_execute_tool` now wraps `MCPError` in `LLMError`, so tool execution failures are caught by the existing cleanup/status/notification handler in `run_job()`. Server connections are no longer leaked. Additionally, `max_tool_rounds` is now a constructor parameter matching the pattern from `max_retries`/`base_delay`. Redundant `str()` tracked in #146. No remaining blocking issues.
fuzzy scheduled this pull request to auto merge when all checks succeed 2026-07-05 17:40:31 +00:00
fuzzy merged commit bdb2476204 into main 2026-07-05 17:40:44 +00:00
fuzzy deleted branch fix/0095-handle-tool-calls 2026-07-05 17:40:44 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
thwap/kronai!137
No description provided.