Skip to content

fix(crew): keep the call id when a parallel tool fails - #118

Closed
CaptainAni187 wants to merge 1 commit into
smallest-inc:mainfrom
CaptainAni187:fix_parallel_tool_error_correlation
Closed

CaptainAni187 wants to merge 1 commit into
smallest-inc:mainfrom
CaptainAni187:fix_parallel_tool_error_correlation

Conversation

@CaptainAni187

@CaptainAni187 CaptainAni187 commented Sep 12, 2026

Copy link
Copy Markdown

When a tool fails in parallel mode, the error ToolResult is built with no id and no name:

results = await asyncio.gather(*tasks, return_exceptions=True)

for result in results:
    if isinstance(result, BaseException):
        final_results.append(
            ToolResult(tool_call_id="", name="", content=str(result), is_error=True)
        )

The call it belongs to is right there in tool_calls, and gather preserves input order, so the pairing is available and just not used.

That matters because these go straight back to the model as tool messages. A tool message has to name the call it answers, so an empty tool_call_id makes the request malformed rather than merely uninformative:

tool_call_id='call_aaa' name='fine'  is_error=False
tool_call_id=''         name=''      is_error=True

the LLM messages these become:
   {'role': 'tool', 'tool_call_id': 'call_aaa', 'name': 'fine', 'content': '"ok"'}
   {'role': 'tool', 'tool_call_id': '', 'name': '', 'content': ''}

Note the content is empty too. str() on an exception constructed without a message is "", so the model is handed a tool message with nothing in it at all.

How you reach that branch

Worth being precise, because it is narrower than it looks. _execute_single already catches every Exception and returns a proper ToolResult with tool_call_id=call.id, so ordinary tool failures never get here. Only a BaseException does, and in practice that means asyncio.CancelledError.

Which is the second half of this. Converting a CancelledError into a ToolResult means a cancelled turn does not stop: it carries on and reports a result for a call that never completed. For a voice agent, where an interruption cancelling in-flight work is routine rather than exceptional, that is the wrong end state.

The change

zip(tool_calls, results) so an error result carries the id and name of its own call, and fall back to the exception's type name when str() on it is empty.

CancelledError is re-raised rather than converted. Cancellation should propagate.

If you would rather not change the cancellation behaviour, the zip alone still fixes the malformed message and I am happy to drop the re-raise.

Tests

tests/custom/test_tool_registry_parallel_errors.py, six cases. Four fail on main: the id and name survive, the content is never blank, results stay aligned with their calls across a mixed success/failure batch, and cancellation propagates. The other two pin what must not change, an ordinary Exception still becoming a tool result with the right id, and the sequential path behaving as before.

src/smallestai/atoms/crew/** is .fernignored, so a regen keeps this.

_execute_single already catches every Exception and returns a ToolResult carrying
the call's id, so the fallback in _execute_parallel only ever sees a BaseException.
It built that result with an empty tool_call_id and name, which reaches the model as
{"role": "tool", "tool_call_id": ""}: a tool message has to name the call it
answers, so the request is malformed rather than just uninformative. gather preserves
order, so pair each result with its own call.

CancelledError is the BaseException that actually turns up here. Re-raise it instead
of turning it into a result, so a cancelled turn stops rather than reporting a
fabricated answer for the call.
@crim-app

crim-app Bot commented Sep 12, 2026

Copy link
Copy Markdown

crim doesn't review pull requests automatically here.

Comment crim review on this pull request whenever you want a review.

@CaptainAni187

Copy link
Copy Markdown
Author

crim review

@crim-app

crim-app Bot commented Sep 12, 2026

Copy link
Copy Markdown

crim is reviewing this pull request. Findings will be posted shortly.

@crim-app crim-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM

One-sentence assessment: A clean, well-tested bug fix that correctly preserves the tool call id/name on the parallel error path and re-raises cancellation instead of masking it.

What this PR does: In _execute_parallel, when asyncio.gather(return_exceptions=True) surfaces a BaseException (which _execute_single does not already convert to a ToolResult), the fallback previously built a ToolResult with tool_call_id="" and name="", producing a malformed tool message the LLM cannot match to its call. The fix zips each result back to its originating call to carry the real id/name, falls back to the exception type name when the message is empty, and re-raises asyncio.CancelledError so a cancelled turn does not proceed with a fabricated result. A new regression test covers all of these paths.

Findings: No issues found.

@crim-app

crim-app Bot commented Sep 12, 2026

Copy link
Copy Markdown

Good to merge

A fix that gives orphaned error results their id back and stops pretending a cancelled turn finished cleanly, with tests for every branch it touches. The empty-string tool_call_id can finally rest in peace.

@abhishekmishragithub

Copy link
Copy Markdown
Collaborator

Superseded by #120 (re-homed onto an upstream branch so CI could run; your commit is included with authorship preserved, and the test type-check gaps for #112/#118 were fixed there). Shipping in 5.12.1.

@abhishekmishragithub

Copy link
Copy Markdown
Collaborator

@CaptainAni187 still thank you for raising this! 🙌🏼

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants