Skip to content

fix: Handle ToolCall Pydantic models correctly#9

Open
marklicata wants to merge 7 commits intomicrosoft:mainfrom
marklicata:main
Open

fix: Handle ToolCall Pydantic models correctly#9
marklicata wants to merge 7 commits intomicrosoft:mainfrom
marklicata:main

Conversation

@marklicata
Copy link
Copy Markdown

Fix: Handle ToolCall Pydantic models correctly

Problem

The orchestrator was failing when processing ToolCall objects with the error:

AttributeError: 'ToolCall' object has no attribute 'get'

Root Cause

The code used this pattern:

getattr(tc, "id", None) or tc.get("id")

This fails when:

  1. tc is a Pydantic model (has attributes, not dict methods)
  2. The attribute exists but has a falsy value (empty string "")
  3. The code tries to call .get() which doesn't exist on Pydantic models

Solution

Use hasattr() to check if the attribute exists, rather than checking if it's truthy:

tc.id if hasattr(tc, "id") else tc.get("id")

This properly handles both:

  • Pydantic models (uses attribute access)
  • Dictionaries (uses .get() method)

Changes

Updated all instances of ToolCall attribute access in:

  • Tool call message formatting (2 places)
  • Tool execution (1 place)
  • Cancellation handling (1 place)

Testing

Verified with custom tool module (tool-msgraph) that uses Pydantic ToolCall models.

Before: Tool calls failed with AttributeError
After: Tool calls execute successfully

bkrabach and others added 7 commits February 10, 2026 12:14
When a hook returns action='modify' on tool:post, the orchestrator now
detects the modified data by comparing the returned result with the
original, and uses it instead of the original get_serialized_output().
This enables tool output truncation, sanitization, and transformation
hooks to work correctly.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
)

Replace bare tc.id and tc.name attribute access with the safe
getattr/dict.get dual-access pattern already used at every other
tool_call access site in the file. Prevents AttributeError when
providers return tool_calls as plain dicts and the user cancels
during tool execution.

Adds regression tests verifying CancelledError handler works with
dict-based tool_calls.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…lledError in tool execution

Harden the orchestrator's result-writing and tool-execution loops against
CancelledError to prevent orphaned tool_calls that break the conversation
state.

When a CancelledError escapes a result-writing loop, the assistant message
is committed without the corresponding tool result. The LLM sees a
tool_use without a tool_result on the next turn, causing a protocol
violation or infinite retry loop.

This fix catches CancelledError at two levels:
- In the result-writing loop, ensuring partial results are committed
- In tool execution, ensuring the tool result is always recorded

Rebased on main to pick up the defensive getattr access pattern for
tool call attributes.

Companion fixes already merged in loop-streaming#14 and loop-events#5.

Fixes: microsoft-amplifier/amplifier-support#46

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…d, observability.events (microsoft#7)

- Add EXECUTION_START and EXECUTION_END imports from amplifier_core.events
- Move asyncio to top-level import (was inline inside execute method)
- Emit execution:start after prompt:submit processing (skipped on deny)
- Wrap main loop body in try/except/finally to emit execution:end on ALL exit paths
  - status='completed' on normal completion
  - status='cancelled' on cancellation paths (loop-start, post-provider, post-tools)
  - status='cancelled' for asyncio.CancelledError exceptions
  - status='error' for all other exceptions
- Add tool_call_id to TOOL_PRE event payload
- Add tool_call_id to TOOL_POST event payload
- Add tool_call_id to both TOOL_ERROR event payloads (not-found and exception paths)
- Register observability.events in mount() with execution:start and execution:end

Tests added in tests/test_lifecycle_events.py:
- test_execution_start_fires_with_prompt
- test_execution_end_fires_on_normal_completion
- test_execution_end_response_matches_return_value
- test_execution_start_not_fired_when_prompt_submit_denied
- test_execution_end_fires_cancelled_at_loop_start
- test_execution_end_fires_cancelled_after_provider
- test_execution_end_fires_on_provider_exception
- test_execution_end_fires_exactly_once
- test_tool_pre_includes_tool_call_id
- test_tool_post_includes_tool_call_id
- test_tool_error_not_found_includes_tool_call_id
- test_tool_error_exception_includes_tool_call_id
- test_mount_registers_observability_events
The orchestrator now sets coordinator._tool_dispatch_context = {
    "tool_call_id": tool_call_id,
    "parallel_group_id": group_id,
} immediately before calling tool.execute() in execute_single_tool,
and clears it in the outer finally block (alongside register_tool_complete).

This lets tools that need framework correlation IDs (e.g. the delegate
tool) read them via:
    dispatch_ctx = getattr(self.coordinator, '_tool_dispatch_context', {})
    tool_call_id = dispatch_ctx.get('tool_call_id', '')

Uses setattr() to avoid type errors on the dynamic private attribute.
The finally block always clears the context to prevent leaking between
parallel tool executions.

Fixes Check 4: delegate:agent_spawned tool_call_id was always empty
because the delegate tool was reading from tool input (which the LLM
never populates with framework IDs).
…PyPI

The [tool.uv.sources] section forced amplifier-core to resolve from git
(requiring a Rust toolchain). Removed so that uv resolves from PyPI,
matching the standard install path.

Also pinned amplifier-core>=1.0.10 in dev dependency-group.
The orchestrator was failing when processing ToolCall objects because it
used getattr(tc, 'id', None) or tc.get('id'), which fails when:
1. tc is a Pydantic model (no .get() method)
2. The attribute exists but is falsy (empty string)

Changed to use hasattr() checks: tc.id if hasattr(tc, 'id') else tc.get('id')

This properly handles both Pydantic models and dictionaries.

Fixes tool execution errors with custom tool modules.
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.

3 participants