diff --git a/src/smallestai/atoms/crew/nodes/output_crew.py b/src/smallestai/atoms/crew/nodes/output_crew.py index 0f0251e6..81a19fd9 100644 --- a/src/smallestai/atoms/crew/nodes/output_crew.py +++ b/src/smallestai/atoms/crew/nodes/output_crew.py @@ -34,6 +34,43 @@ # 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) + 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"} + + from smallestai.atoms.crew.nodes.base import CrewNode from smallestai.atoms.crew.task_manager import TaskManager @@ -76,6 +113,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 +128,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..1c7f5673 --- /dev/null +++ b/tests/custom/test_crew_tool_response_enrichment.py @@ -0,0 +1,120 @@ +"""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, + WarmTransferHandoffOptionType, + WarmTransferPrivateHandoffOption, +) +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, + 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()) + + @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 _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(): + 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" + assert resp["transfer_type"] == TransferOptionType.WARM_TRANSFER.value + # 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(): + 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(): + 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). + ctx = _end_event(_run("noop"), "noop").payload["context"] + assert "response" not in ctx + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"])