Summary
When a model requests the same tool more than once in a single response (parallel tool calling — e.g. search({"q": "X"}) and search({"q": "Y"}) in one turn), Mellea executes only the last call and silently drops the rest.
Root cause
Model tool calls are stored in a dict keyed by tool name:
ModelOutputThunk.tool_calls: dict[str, ModelToolCall] (mellea/core/base.py)
- Populated per backend via
model_tool_calls[tool_name] = ModelToolCall(...):
mellea/helpers/openai_compatible_helpers.py (OpenAI-compatible, LiteLLM, Watsonx)
mellea/backends/ollama.py
Because the key is the tool name, a second call to the same name overwrites the first before execution. _acall_tools (mellea/stdlib/functional.py) then iterates the collapsed dict, so the tool runs once and produces a single ToolMessage. The dropped call's result never returns to the model.
This dates to the initial commit and predates parallel tool calling being common; the "one call per tool name per turn" assumption is no longer valid for current OpenAI/Anthropic-style APIs.
Impact
- Correctness (primary): parallel calls to the same tool with different arguments lose all but the last. Affects tool execution and the messages fed back to the model — not just observability.
- Observability (symptom): the
mellea.tool.calls metric and the execute_tool span each fire once instead of N times. This faithfully reflects that only one call executed — the telemetry is correct about a broken execution. No telemetry code needs to change.
Suggested fix
Key tool calls by the provider-supplied call id rather than by name. ModelToolCall.tool_call_id (added in #1430) is the field a fix would key on. This is a data-model change (dict[str, ModelToolCall] → keyed by call id, or a list) touching core/base.py and all four backend extraction sites; no telemetry code needs to change — the correct span/metric cardinality falls out automatically once execution stops collapsing.
Related
Summary
When a model requests the same tool more than once in a single response (parallel tool calling — e.g.
search({"q": "X"})andsearch({"q": "Y"})in one turn), Mellea executes only the last call and silently drops the rest.Root cause
Model tool calls are stored in a dict keyed by tool name:
ModelOutputThunk.tool_calls: dict[str, ModelToolCall](mellea/core/base.py)model_tool_calls[tool_name] = ModelToolCall(...):mellea/helpers/openai_compatible_helpers.py(OpenAI-compatible, LiteLLM, Watsonx)mellea/backends/ollama.pyBecause the key is the tool name, a second call to the same name overwrites the first before execution.
_acall_tools(mellea/stdlib/functional.py) then iterates the collapsed dict, so the tool runs once and produces a singleToolMessage. The dropped call's result never returns to the model.This dates to the initial commit and predates parallel tool calling being common; the "one call per tool name per turn" assumption is no longer valid for current OpenAI/Anthropic-style APIs.
Impact
mellea.tool.callsmetric and theexecute_toolspan each fire once instead of N times. This faithfully reflects that only one call executed — the telemetry is correct about a broken execution. No telemetry code needs to change.Suggested fix
Key tool calls by the provider-supplied call id rather than by name.
ModelToolCall.tool_call_id(added in #1430) is the field a fix would key on. This is a data-model change (dict[str, ModelToolCall]→ keyed by call id, or a list) touchingcore/base.pyand all four backend extraction sites; no telemetry code needs to change — the correct span/metric cardinality falls out automatically once execution stops collapsing.Related
execute_toolspan work (feat(telemetry): emit execute_tool spans for tool calls #1430), which addedModelToolCall.tool_call_idbut deliberately does not attempt the data-model change.tool_call_idfeedback).