Skip to content

Commit 2a3e64d

Browse files
committed
Fix tool-call extraction and populate events on the run() path
Tool tasks were identified by a `call_` reference-name prefix. That prefix is the LLM provider's tool-call id format, not something the server adds, so an Anthropic-backed agent (`toolu_…`) recorded no tool calls at all. Five tool kinds were also filtered out as system tasks and five more were named from the task type, and `run()` never passed `events=` at all. Identify a tool by the task type it compiled to, per the server's `ToolCompiler.TYPE_MAP`, and resolve its name from `_agent_tool_name` — which the tool-dispatch script sets on every tool kind — falling back to `method` and the task definition. Nothing reads the reference name for identity, so nothing depends on provider-controlled data. Names are no longer case-folded, so `getWeather` stops arriving as `getweather`. `agent_tool` and a strategy handoff both compile to SUB_WORKFLOW; only the tool carries `_agent_tool_name`, which is what tells them apart. The agent's own statically-compiled workers — guardrails, callbacks, gates, routing — compile to SIMPLE tasks like a worker tool does, so they are excluded by name; that check is consulted only after the dispatch key has failed to settle the question. `run()` and `run_async()` now derive `events` from the execution they already fetch for `tool_calls`, with no extra call. Eight assertions in `conductor.ai.agents.testing` read `events`, and four of them check for absence — including `assert_no_errors`, which the eval runner runs on every case — so they were passing without evidence. `expect_handoff_to`, used by the eval runner's own documented example, was failing every time. The task-to-event mapping was copy-pasted across the sync and async polling streams; both now share `_task_events`, which is also what `run()` uses, so an assertion reads the same whichever way the agent was run. Verified against a live server: before, an Anthropic-backed agent with one tool returned `tool_calls: []` and `events: []`; after, `[('getWeather', {'city': 'Tokyo'})]` and a full event list. The OpenAI path is unchanged apart from the name no longer being lowercased.
1 parent c99e2cf commit 2a3e64d

5 files changed

Lines changed: 891 additions & 294 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
- Worker processes killed by a signal now log a diagnostic hint (signal number, `PYTHONFAULTHANDLER=1` guidance) instead of restarting silently
3030
- Per-schedule pause/resume now work on both Conductor server families: the client sends `PUT` (the OSS Conductor dialect — the spec-generated `GET` failed there) and transparently falls back to `GET` on a 405 for Orkes servers
3131
- `Agent(model="claude-code/...")` registration no longer crashes with `SpawnSafetyError`/`PicklingError` under the `spawn` start method (top-level or nested as a sub-agent); the tool worker is now built through the same picklable passthrough path already used by other frameworks
32+
- `AgentResult.tool_calls` no longer drops or misnames tool calls. A tool task was identified by a `call_` reference-name prefix, which is the LLM provider's tool-call id format — so an Anthropic-backed agent (`toolu_…`) recorded no tool calls at all. Tools are now identified by the task type they compiled to, and named from `_agent_tool_name` (set by the server on every tool kind), `method`, or the task definition. Consequences: `http`, `api`, `mcp`, `agent_tool`, `human` and `pull_workflow_messages` tools now appear at all; media and RAG tools report their own name instead of `generate_image`/`llm_index_text`; and a camelCase worker name is no longer lowercased, so `assert_tool_used(result, "getWeather")` matches
33+
- `AgentResult.events` is now populated by `run()` and `run_async()` for natively-compiled agents, derived from the same execution already fetched for `tool_calls` rather than from an extra call. It was previously only filled in when an `on_event` callback or `stream()` was used, so the eight `conductor.ai.agents.testing` assertions that read events — `assert_no_errors`, `assert_handoff_to`, `assert_events_contain`, `assert_max_turns` and the rest — saw an empty list. Four of them check for *absence* and so passed vacuously, `assert_no_errors` on every eval case (`expect_no_errors` defaults to `True`); `expect_handoff_to`, used by the eval runner's own documented example, failed every time. Foreign-framework agents (LangChain, LangGraph, OpenAI Agents, Claude Agent SDK) are unchanged: their events come from the stream, since the whole agent runs inside one passthrough task that polling cannot decompose
34+
- Tool arguments reported on `AgentEvent.args` and in `AgentResult.tool_calls` now agree, and both strip every key Conductor injects — previously `_agent_tool_name`, `_allowed_commands`, `__humanTaskDefinition` and `__conductor_agent_ctx__` leaked into one surface or the other

‎src/conductor/ai/agents/result.py‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ class AgentResult:
106106
error: Human-readable error message when the agent failed.
107107
token_usage: Aggregated token usage across all LLM calls.
108108
metadata: Extra data from the workflow execution.
109+
events: The execution's event history (:class:`AgentEvent`), whether the
110+
agent was streamed or run to completion.
109111
sub_results: Per-agent outputs for multi-agent strategies (parallel).
110112
Empty dict for single-agent runs. Keyed by agent name.
111113
"""
@@ -691,6 +693,22 @@ def __repr__(self) -> str:
691693
# ── AgentEvent (yielded by stream()) ───────────────────────────────────
692694

693695

696+
#: Keys Conductor injects into a tool task's input that are not arguments the
697+
#: LLM chose. ``method`` and ``_agent_tool_name`` carry the tool's *name*;
698+
#: the rest are agent-loop plumbing. Shared by :class:`AgentEvent` and the
699+
#: runtime's ``tool_calls`` extraction so both report the same arguments.
700+
INTERNAL_ARG_KEYS = frozenset(
701+
{
702+
"_agent_state",
703+
"_agent_tool_name",
704+
"_allowed_commands",
705+
"method",
706+
"__humanTaskDefinition",
707+
"__conductor_agent_ctx__",
708+
}
709+
)
710+
711+
694712
class EventType(str, Enum):
695713
"""Types of events emitted during agent execution."""
696714

@@ -723,9 +741,6 @@ class AgentEvent:
723741
guardrail_name: Guardrail name (for ``guardrail_pass``, ``guardrail_fail``).
724742
"""
725743

726-
# Keys injected by Conductor that should not appear in user-facing args.
727-
_INTERNAL_ARG_KEYS = frozenset({"_agent_state", "method"})
728-
729744
type: str
730745
content: Optional[str] = None
731746
tool_name: Optional[str] = None
@@ -738,7 +753,7 @@ class AgentEvent:
738753

739754
def __post_init__(self):
740755
if self.args and isinstance(self.args, dict):
741-
cleaned = {k: v for k, v in self.args.items() if k not in self._INTERNAL_ARG_KEYS}
756+
cleaned = {k: v for k, v in self.args.items() if k not in INTERNAL_ARG_KEYS}
742757
object.__setattr__(self, "args", cleaned if cleaned else None)
743758

744759

0 commit comments

Comments
 (0)