From 65322bc44b0b0a17a7c409055a26a12487b58107 Mon Sep 17 00:00:00 2001 From: Abhishek Mishra Date: Thu, 20 Aug 2026 13:52:08 +0530 Subject: [PATCH 1/3] fix(crew): SDK auto-fills tool_call response for transfer/end-call (no user code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crew transfer/end-call tools are fire-and-forget: they emit an SDKAgentTransferConversationEvent / SDKAgentEndCallEvent and return nothing, so the tool_call event showed only {arguments} with no response — unlike single-prompt agents, whose built-in transfer tool returns {status, transfer_number}. Rather than make users return a dict from their @function_tool, the SDK now does it: OutputCrewNode.send_event (which already latches handoff events) records a handoff summary, and ToolRegistry._execute_single attaches it as the tool_call_end response when the tool itself returned None. Same shape single-prompt surfaces (status/action/transfer_number/transfer_type/on_hold_music), zero user code. Only the observability event is enriched; the tool's own return value and the LLM-facing result are unchanged. Tests: transfer + end-call tools get the handoff response; a plain None tool still has no response. --- .../atoms/crew/nodes/output_crew.py | 29 +++++++ src/smallestai/atoms/crew/tools/registry.py | 17 +++- .../test_crew_tool_response_enrichment.py | 86 +++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/custom/test_crew_tool_response_enrichment.py diff --git a/src/smallestai/atoms/crew/nodes/output_crew.py b/src/smallestai/atoms/crew/nodes/output_crew.py index 0f0251e6..fbf946c3 100644 --- a/src/smallestai/atoms/crew/nodes/output_crew.py +++ b/src/smallestai/atoms/crew/nodes/output_crew.py @@ -34,6 +34,29 @@ # Events that hand the call off / end it. Once one is emitted the conversation is # over for this node, so we stop responding to further LLM requests. _HANDOFF_EVENTS = (SDKAgentTransferConversationEvent, SDKAgentEndCallEvent) + + +def _handoff_summary(event: SDKEvent) -> Dict[str, Any]: + """Structured summary of a handoff, used to enrich the tool_call event's + `response` when a `@function_tool` triggers a transfer / end-call but returns + nothing itself. Mirrors what single-prompt agents surface (status + + destination), so crew tool-call events carry the same detail with no user code. + """ + if isinstance(event, SDKAgentTransferConversationEvent): + opts = getattr(event, "transfer_options", None) + ttype = getattr(opts, "type", None) + return { + "status": "success", + "action": "transfer_call", + "transfer_number": getattr(event, "transfer_call_number", None), + "transfer_type": getattr(ttype, "value", ttype), + "on_hold_music": getattr(event, "on_hold_music", None), + } + if isinstance(event, SDKAgentEndCallEvent): + return {"status": "success", "action": "end_call"} + return {"status": "success"} + + from smallestai.atoms.crew.nodes.base import CrewNode from smallestai.atoms.crew.task_manager import TaskManager @@ -76,6 +99,11 @@ def __init__(self, name: str, is_interruptible: bool = True): # tool_result is recorded), so the LLM keeps re-deciding the same action # on every subsequent request — repeatedly re-firing the event / speech. self._handoff_started = False + # Set when a handoff event is emitted during a tool call; the + # ToolRegistry reads it to enrich that tool's tool_call_end `response` + # even when the tool itself returns nothing. Reset per tool by the + # registry before each execution. + self._pending_tool_response: Any = None async def start(self, init_event: SDKSystemInitEvent, task_manager: TaskManager): """Start the node""" @@ -86,6 +114,7 @@ async def send_event(self, event: SDKEvent): transfer / end-call. Preserves base behavior otherwise.""" if isinstance(event, _HANDOFF_EVENTS): self._handoff_started = True + self._pending_tool_response = _handoff_summary(event) await super().send_event(event) async def _update_settings(self, settings: Dict[str, Any]): diff --git a/src/smallestai/atoms/crew/tools/registry.py b/src/smallestai/atoms/crew/tools/registry.py index 0405a470..71218700 100644 --- a/src/smallestai/atoms/crew/tools/registry.py +++ b/src/smallestai/atoms/crew/tools/registry.py @@ -254,6 +254,10 @@ async def _execute_single(self, call: ToolCall, context: Optional[Any]) -> ToolR await self._emit_tool_event("tool_call_start", call, arguments) + # Clear any handoff summary so it reflects only this tool's effects. + if hasattr(self._owner, "_pending_tool_response"): + self._owner._pending_tool_response = None + func = tool_info.function args, kwargs = self._prepare_arguments(func, arguments, context) @@ -271,11 +275,22 @@ async def _execute_single(self, call: ToolCall, context: Optional[Any]) -> ToolR logger.debug(f"Tool {call.name} completed successfully") + # If the tool returned nothing but triggered a handoff (transfer / + # end-call) via the node, surface the handoff summary as the event's + # response — so the platform shows the same detail single-prompt does, + # without the user having to return it. Only enriches the event; the + # tool's own return value (and the LLM-facing result) is unchanged. + event_response = result + if result is None: + side_effect = getattr(self._owner, "_pending_tool_response", None) + if side_effect: + event_response = side_effect + await self._emit_tool_event( "tool_call_end", call, arguments, - response=result, + response=event_response, latency_ms=int((time.monotonic() - started) * 1000), success=True, ) diff --git a/tests/custom/test_crew_tool_response_enrichment.py b/tests/custom/test_crew_tool_response_enrichment.py new file mode 100644 index 00000000..80f34e6a --- /dev/null +++ b/tests/custom/test_crew_tool_response_enrichment.py @@ -0,0 +1,86 @@ +"""A crew @function_tool that triggers a transfer/end-call but returns nothing +still gets a rich tool_call_end `response` (handoff summary), matching what +single-prompt agents surface — with no user code returning a dict. +""" + +import asyncio + +from smallestai.atoms.crew.clients.types import ToolCall +from smallestai.atoms.crew.events import ( + SDKAgentEndCallEvent, + SDKAgentLogEvent, + SDKAgentTransferConversationEvent, + TransferOption, + TransferOptionType, +) +from smallestai.atoms.crew.nodes import OutputCrewNode +from smallestai.atoms.crew.tools import ToolRegistry, function_tool + + +class _Node(OutputCrewNode): + def __init__(self): + super().__init__(name="t") + self.sent = [] + self.registry = ToolRegistry() + self.registry.discover(self) + + async def send_event(self, event): + self.sent.append(event) + await super().send_event(event) # runs handoff latch + _pending_tool_response + + @function_tool(name="transfer_call") + async def transfer_call(self) -> None: # returns nothing, like the canonical example + await self.send_event( + SDKAgentTransferConversationEvent( + transfer_call_number="+917900135795", + transfer_options=TransferOption(type=TransferOptionType.WARM_TRANSFER), + on_hold_music="relaxing_sound", + ) + ) + + @function_tool(name="hang_up") + async def hang_up(self) -> None: + await self.send_event(SDKAgentEndCallEvent()) + + @function_tool(name="noop") + async def noop(self) -> None: + return None + + +def _end_event(node, fn): + for e in node.sent: + if isinstance(e, SDKAgentLogEvent) and e.name == "tool_call_end" and e.payload["function_name"] == fn: + return e + return None + + +def test_transfer_tool_gets_handoff_response(): + node = _Node() + asyncio.run(node.registry.execute([ToolCall(id="c1", name="transfer_call", arguments="{}")], parallel=False)) + resp = _end_event(node, "transfer_call").payload["context"]["response"] + assert resp["status"] == "success" + assert resp["action"] == "transfer_call" + assert resp["transfer_number"] == "+917900135795" + assert resp["transfer_type"] == TransferOptionType.WARM_TRANSFER.value + assert resp["on_hold_music"] == "relaxing_sound" + + +def test_end_call_tool_gets_handoff_response(): + node = _Node() + asyncio.run(node.registry.execute([ToolCall(id="c2", name="hang_up", arguments="{}")], parallel=False)) + resp = _end_event(node, "hang_up").payload["context"]["response"] + assert resp == {"status": "success", "action": "end_call"} + + +def test_plain_none_tool_has_no_response(): + # A tool that returns None and triggers no handoff stays as before (no response key). + node = _Node() + asyncio.run(node.registry.execute([ToolCall(id="c3", name="noop", arguments="{}")], parallel=False)) + ctx = _end_event(node, "noop").payload["context"] + assert "response" not in ctx + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"]) From 62e05a676efa0bae140cb974cde91f4913819cc7 Mon Sep 17 00:00:00 2001 From: Abhishek Mishra Date: Thu, 20 Aug 2026 14:09:20 +0530 Subject: [PATCH 2/3] fix(crew): include warm-transfer handoff (whisper) in the tool_call response Transfer has two kinds: cold (direct connect) and warm (brief the specialist, then bridge). The event summary now reflects which happened: warm transfers add the private/public handoff option (type + prompt); cold transfers carry none. transfer_type (cold_transfer|warm_transfer) + on_hold_music already distinguished them; this adds the whisper detail for warm. --- .../atoms/crew/nodes/output_crew.py | 16 +++++++++- .../test_crew_tool_response_enrichment.py | 31 +++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/smallestai/atoms/crew/nodes/output_crew.py b/src/smallestai/atoms/crew/nodes/output_crew.py index fbf946c3..81a19fd9 100644 --- a/src/smallestai/atoms/crew/nodes/output_crew.py +++ b/src/smallestai/atoms/crew/nodes/output_crew.py @@ -45,13 +45,27 @@ def _handoff_summary(event: SDKEvent) -> Dict[str, Any]: if isinstance(event, SDKAgentTransferConversationEvent): opts = getattr(event, "transfer_options", None) ttype = getattr(opts, "type", None) - return { + summary: Dict[str, Any] = { "status": "success", "action": "transfer_call", "transfer_number": getattr(event, "transfer_call_number", None), "transfer_type": getattr(ttype, "value", ttype), "on_hold_music": getattr(event, "on_hold_music", None), } + # Warm transfers carry a handoff briefing (the whisper spoken to the + # specialist / caller before bridging); cold transfers don't. Include it + # so the event reflects which kind of transfer actually happened. + for key, opt in ( + ("private_handoff", getattr(opts, "private_handoff_option", None)), + ("public_handoff", getattr(opts, "public_handoff_option", None)), + ): + if opt is not None: + otype = getattr(opt, "type", None) + summary[key] = { + "type": getattr(otype, "value", otype), + "prompt": getattr(opt, "prompt", None), + } + return summary if isinstance(event, SDKAgentEndCallEvent): return {"status": "success", "action": "end_call"} return {"status": "success"} diff --git a/tests/custom/test_crew_tool_response_enrichment.py b/tests/custom/test_crew_tool_response_enrichment.py index 80f34e6a..9851c359 100644 --- a/tests/custom/test_crew_tool_response_enrichment.py +++ b/tests/custom/test_crew_tool_response_enrichment.py @@ -12,6 +12,8 @@ SDKAgentTransferConversationEvent, TransferOption, TransferOptionType, + WarmTransferHandoffOptionType, + WarmTransferPrivateHandoffOption, ) from smallestai.atoms.crew.nodes import OutputCrewNode from smallestai.atoms.crew.tools import ToolRegistry, function_tool @@ -33,11 +35,26 @@ async def transfer_call(self) -> None: # returns nothing, like the canonical ex await self.send_event( SDKAgentTransferConversationEvent( transfer_call_number="+917900135795", - transfer_options=TransferOption(type=TransferOptionType.WARM_TRANSFER), + transfer_options=TransferOption( + type=TransferOptionType.WARM_TRANSFER, + private_handoff_option=WarmTransferPrivateHandoffOption( + type=WarmTransferHandoffOptionType.PROMPT, + prompt="Brief the specialist on the caller's issue.", + ), + ), on_hold_music="relaxing_sound", ) ) + @function_tool(name="cold_transfer") + async def cold_transfer(self) -> None: + await self.send_event( + SDKAgentTransferConversationEvent( + transfer_call_number="+911234567890", + transfer_options=TransferOption(type=TransferOptionType.COLD_TRANSFER), + ) + ) + @function_tool(name="hang_up") async def hang_up(self) -> None: await self.send_event(SDKAgentEndCallEvent()) @@ -62,7 +79,17 @@ def test_transfer_tool_gets_handoff_response(): assert resp["action"] == "transfer_call" assert resp["transfer_number"] == "+917900135795" assert resp["transfer_type"] == TransferOptionType.WARM_TRANSFER.value - assert resp["on_hold_music"] == "relaxing_sound" + # warm transfer includes the whisper briefing + assert resp["private_handoff"]["type"] == WarmTransferHandoffOptionType.PROMPT.value + assert "specialist" in resp["private_handoff"]["prompt"] + + +def test_cold_transfer_has_no_handoff(): + node = _Node() + asyncio.run(node.registry.execute([ToolCall(id="c4", name="cold_transfer", arguments="{}")], parallel=False)) + resp = _end_event(node, "cold_transfer").payload["context"]["response"] + assert resp["transfer_type"] == TransferOptionType.COLD_TRANSFER.value + assert "private_handoff" not in resp and "public_handoff" not in resp def test_end_call_tool_gets_handoff_response(): From 1f43802b7eee0c2b5bd2edef84f6f7bb60de47e2 Mon Sep 17 00:00:00 2001 From: Abhishek Mishra Date: Thu, 20 Aug 2026 14:23:26 +0530 Subject: [PATCH 3/3] test(crew): build the node inside the running loop (py3.9 get_event_loop) CrewNode init grabs the event loop, which raises on Python 3.9 when there is no current loop. Construct the node inside asyncio.run so the handoff-response tests pass on 3.9. --- .../test_crew_tool_response_enrichment.py | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/custom/test_crew_tool_response_enrichment.py b/tests/custom/test_crew_tool_response_enrichment.py index 9851c359..1c7f5673 100644 --- a/tests/custom/test_crew_tool_response_enrichment.py +++ b/tests/custom/test_crew_tool_response_enrichment.py @@ -71,10 +71,23 @@ def _end_event(node, fn): return None +def _run(tool_name, call_id="c"): + """Build the node and run one tool, all inside a running loop. + + The node must be constructed inside asyncio.run: CrewNode init grabs the + event loop, which raises on Python 3.9 when there's no current loop. + """ + + async def _body(): + node = _Node() + await node.registry.execute([ToolCall(id=call_id, name=tool_name, arguments="{}")], parallel=False) + return node + + return asyncio.run(_body()) + + def test_transfer_tool_gets_handoff_response(): - node = _Node() - asyncio.run(node.registry.execute([ToolCall(id="c1", name="transfer_call", arguments="{}")], parallel=False)) - resp = _end_event(node, "transfer_call").payload["context"]["response"] + resp = _end_event(_run("transfer_call"), "transfer_call").payload["context"]["response"] assert resp["status"] == "success" assert resp["action"] == "transfer_call" assert resp["transfer_number"] == "+917900135795" @@ -85,25 +98,19 @@ def test_transfer_tool_gets_handoff_response(): def test_cold_transfer_has_no_handoff(): - node = _Node() - asyncio.run(node.registry.execute([ToolCall(id="c4", name="cold_transfer", arguments="{}")], parallel=False)) - resp = _end_event(node, "cold_transfer").payload["context"]["response"] + resp = _end_event(_run("cold_transfer"), "cold_transfer").payload["context"]["response"] assert resp["transfer_type"] == TransferOptionType.COLD_TRANSFER.value assert "private_handoff" not in resp and "public_handoff" not in resp def test_end_call_tool_gets_handoff_response(): - node = _Node() - asyncio.run(node.registry.execute([ToolCall(id="c2", name="hang_up", arguments="{}")], parallel=False)) - resp = _end_event(node, "hang_up").payload["context"]["response"] + resp = _end_event(_run("hang_up"), "hang_up").payload["context"]["response"] assert resp == {"status": "success", "action": "end_call"} def test_plain_none_tool_has_no_response(): # A tool that returns None and triggers no handoff stays as before (no response key). - node = _Node() - asyncio.run(node.registry.execute([ToolCall(id="c3", name="noop", arguments="{}")], parallel=False)) - ctx = _end_event(node, "noop").payload["context"] + ctx = _end_event(_run("noop"), "noop").payload["context"] assert "response" not in ctx