Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/smallestai/atoms/crew/nodes/output_crew.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"""
Expand All @@ -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]):
Expand Down
17 changes: 16 additions & 1 deletion src/smallestai/atoms/crew/tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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,
)
Expand Down
120 changes: 120 additions & 0 deletions tests/custom/test_crew_tool_response_enrichment.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading