-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add tool call lifecycle events and handler registration #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sr-anam
wants to merge
11
commits into
main
Choose a base branch
from
feat/handle-tool-lifecycle-events
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
038c848
feat: add tool call lifecycle events and handler registration
sr-anam dffbb8f
Apply suggestions from code review
sr-anam c33a056
allow part implementation of ToolCallHandler
sr-anam f87e18b
fix tool call handler docs
sr-anam f552d55
example script for tool call handlers
sr-anam 75c7f6b
Merge remote-tracking branch 'origin/main' into feat/handle-tool-life…
sr-anam 6372e3a
add ephemeral tools setup
sr-anam fa6169b
Update src/anam/types.py
sr-anam eeb3134
fix comment
sr-anam adcc0a5
fix tool_call_test
sr-anam 1aebb31
Apply suggestions from code review
sr-anam File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,307 @@ | ||
| """Tool call handler example — client-side logging for tool events. | ||
|
|
||
| This example shows how to: | ||
| 1. Define a client tool (get_weather) on an ephemeral persona | ||
| 2. Register a ToolCallHandler to observe tool call lifecycle events | ||
| 3. Log when a tool starts, completes, or fails on the client side | ||
|
|
||
| When the user asks about the weather, the LLM invokes the get_weather | ||
| tool. The handler logs the event and returns a dummy result so the | ||
| LLM can incorporate it into its response. | ||
|
|
||
| Requirements: | ||
| uv sync --extra display | ||
| # or: pip install opencv-python sounddevice | ||
|
|
||
| Usage: | ||
| export ANAM_API_KEY="your-api-key" | ||
| export ANAM_AVATAR_ID="your-avatar-id" | ||
| export ANAM_VOICE_ID="your-voice-id" | ||
| export ANAM_LLM_ID="your-llm-id" | ||
| uv run --extra display python examples/tool_call_handler.py | ||
| """ | ||
|
|
||
| import asyncio | ||
| import json | ||
| import logging | ||
| import os | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| from dotenv import load_dotenv | ||
|
|
||
| from anam import AnamClient, AnamEvent, ClientOptions | ||
| from anam.types import ( | ||
| ClientToolConfig, | ||
| PersonaConfig, | ||
| ToolCallCompletedPayload, | ||
| ToolCallFailedPayload, | ||
| ToolCallHandler, | ||
| ToolCallStartedPayload, | ||
| ToolParametersConfig, | ||
| ) | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).parent.parent)) | ||
| from examples.utils import AudioPlayer, VideoDisplay, async_input | ||
|
|
||
| load_dotenv() | ||
|
|
||
| REQUIRED_ENV_VARS = ["ANAM_API_KEY", "ANAM_AVATAR_ID", "ANAM_VOICE_ID", "ANAM_LLM_ID"] | ||
| missing = [v for v in REQUIRED_ENV_VARS if not os.getenv(v)] | ||
| if missing: | ||
| raise EnvironmentError(f"Missing required environment variables: {', '.join(missing)}") | ||
|
|
||
| logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s") | ||
| logging.getLogger("anam").setLevel(logging.WARNING) | ||
| logging.getLogger("websockets").setLevel(logging.WARNING) | ||
| logging.getLogger("aiortc").setLevel(logging.WARNING) | ||
| logging.getLogger("aioice").setLevel(logging.WARNING) | ||
|
|
||
|
|
||
| class WeatherToolHandler(ToolCallHandler): | ||
| """Handles get_weather tool calls on the client side. | ||
|
|
||
| Logs the tool call lifecycle and returns a dummy weather result. | ||
| In a real application you would call an external API here. | ||
| """ | ||
|
|
||
| async def on_start(self, payload: ToolCallStartedPayload) -> str | None: | ||
| city = payload.arguments.get("city", "unknown") | ||
| print(f"\n[get_weather] Started — city={city!r}") | ||
| # Return a dummy result. The LLM wont use this in its response. | ||
| return json.dumps({ | ||
| "city": city, | ||
| "temperature_celsius": 18, | ||
| "condition": "Partly cloudy", | ||
| }) | ||
|
|
||
| async def on_complete(self, payload: ToolCallCompletedPayload) -> None: | ||
| print(f"[get_weather] Completed in {payload.execution_time:.0f}ms") | ||
|
|
||
| async def on_fail(self, payload: ToolCallFailedPayload) -> None: | ||
| print(f"[get_weather] Failed: {payload.error_message}") | ||
|
|
||
|
|
||
| async def interactive_loop(session, display: VideoDisplay) -> None: | ||
| """Interactive command loop.""" | ||
| print("\n" + "=" * 60) | ||
| print("Tool Call Handler Example") | ||
| print("=" * 60) | ||
| print("Commands:") | ||
| print(" m <message> - Send message (trigger tool calls via LLM)") | ||
| print(" t <text> - Talk (bypass LLM, direct TTS)") | ||
| print(" i - Interrupt") | ||
| print(" q - Quit") | ||
| print() | ||
| print("Try: 'm What is the weather in Dublin?'") | ||
| print("=" * 60 + "\n") | ||
|
|
||
| while True: | ||
| try: | ||
| user_input = await async_input(">> ") | ||
| parts = user_input.strip().split() | ||
| if not parts: | ||
| continue | ||
|
|
||
| command = parts[0].lower() | ||
|
|
||
| if command == "q": | ||
| print("Exiting...") | ||
| display.stop() | ||
| break | ||
| elif command == "m": | ||
| if len(parts) < 2: | ||
| print("Usage: m <message>") | ||
| continue | ||
| text = " ".join(parts[1:]) | ||
| await session.send_message(text) | ||
| print(f"Sent: {text}") | ||
| elif command == "t": | ||
| if len(parts) < 2: | ||
| print("Usage: t <text>") | ||
| continue | ||
| text = " ".join(parts[1:]) | ||
| await session.send_talk_stream(text) | ||
| print(f"Talk: {text}") | ||
| elif command == "i": | ||
| await session.interrupt() | ||
| print("Interrupted") | ||
| else: | ||
| print(f"Unknown command: {command}") | ||
|
|
||
| except KeyboardInterrupt: | ||
| print("\nInterrupted by user") | ||
| break | ||
| except Exception as e: | ||
| print(f"Error: {e}") | ||
|
|
||
|
|
||
| async def stream_session( | ||
| client: AnamClient, | ||
| display: VideoDisplay, | ||
| audio_player: AudioPlayer, | ||
| ) -> None: | ||
| """Run the streaming session.""" | ||
|
|
||
| @client.on(AnamEvent.CONNECTION_ESTABLISHED) | ||
| async def on_connected() -> None: | ||
| print("Connected!") | ||
|
|
||
| @client.on(AnamEvent.CONNECTION_CLOSED) | ||
| async def on_closed(code: str, reason: str | None) -> None: | ||
| print(f"Connection closed: {code} - {reason or 'User initiated'}") | ||
|
|
||
| @client.on(AnamEvent.MESSAGE_STREAM_EVENT_RECEIVED) | ||
| async def on_message_stream(event) -> None: | ||
| if event.content_index == 0: | ||
| role = "USER" if event.role.value == "user" else "AVATAR" | ||
| print(f"\n[{role}] ", end="", flush=True) | ||
| print(event.content, end="", flush=True) | ||
| if event.end_of_speech: | ||
| print() | ||
|
|
||
| async def consume_video(session) -> None: | ||
| try: | ||
| async for frame in session.video_frames(): | ||
| display.update(frame) | ||
| except Exception as e: | ||
| logging.getLogger(__name__).error(f"Video error: {e}") | ||
|
|
||
| async def consume_audio(session) -> None: | ||
| try: | ||
| async for frame in session.audio_frames(): | ||
| audio_player.add_frame(frame) | ||
| except Exception as e: | ||
| logging.getLogger(__name__).error(f"Audio error: {e}") | ||
|
|
||
| async with client.connect() as session: | ||
| print(f"Session: {session.session_id}") | ||
|
|
||
| video_task = asyncio.create_task(consume_video(session)) | ||
| audio_task = asyncio.create_task(consume_audio(session)) | ||
| interactive_task = asyncio.create_task(interactive_loop(session, display)) | ||
|
|
||
| while display.is_running(): | ||
| if not session.is_active: | ||
| break | ||
| if interactive_task.done(): | ||
| break | ||
| await asyncio.sleep(0.1) | ||
|
|
||
| video_task.cancel() | ||
| audio_task.cancel() | ||
| if not interactive_task.done(): | ||
| interactive_task.cancel() | ||
|
|
||
| for task in [video_task, audio_task, interactive_task]: | ||
| try: | ||
| await task | ||
| except asyncio.CancelledError: | ||
| pass | ||
| except Exception as e: | ||
| logging.getLogger(__name__).error(f"Task error: {e}") | ||
|
|
||
| if session.is_active: | ||
| try: | ||
| await session.close() | ||
| except Exception as e: | ||
| logging.getLogger(__name__).error(f"Close error: {e}") | ||
|
|
||
|
|
||
| def main() -> None: | ||
| api_key = os.environ.get("ANAM_API_KEY", "").strip().strip('"') | ||
| avatar_id = os.environ.get("ANAM_AVATAR_ID", "").strip().strip('"') | ||
| voice_id = os.environ.get("ANAM_VOICE_ID", "").strip().strip('"') | ||
| llm_id = os.environ.get("ANAM_LLM_ID", "").strip().strip('"') | ||
| avatar_model = os.environ.get("ANAM_AVATAR_MODEL") | ||
| api_base_url = os.environ.get("ANAM_API_BASE_URL", "https://api.anam.ai").strip().strip('"') | ||
|
|
||
| # Define the get_weather client tool inline on the ephemeral persona. | ||
| get_weather_tool = ClientToolConfig( | ||
| name="get_weather", | ||
| description="Get the current weather for a city.", | ||
| parameters=ToolParametersConfig( | ||
| properties={ | ||
| "city": { | ||
| "type": "string", | ||
| "description": "The city to get weather for", | ||
| }, | ||
| }, | ||
| required=["city"], | ||
| ), | ||
| ) | ||
|
|
||
| persona_config = PersonaConfig( | ||
| avatar_id=avatar_id, | ||
| voice_id=voice_id, | ||
| llm_id=llm_id, | ||
| avatar_model=avatar_model, | ||
| system_prompt=( | ||
| "You are a helpful assistant. When the user asks about the weather " | ||
| "in a city, use the get_weather tool. Keep responses short." | ||
| ), | ||
| tools=[get_weather_tool], | ||
| ) | ||
| print(f"Using persona: avatar={avatar_id}, voice={voice_id}, llm={llm_id}") | ||
|
|
||
| client = AnamClient( | ||
| api_key=api_key, | ||
| persona_config=persona_config, | ||
| options=ClientOptions(api_base_url=api_base_url), | ||
| ) | ||
|
|
||
| # Register the handler. When the LLM calls get_weather, | ||
| # WeatherToolHandler.on_start() logs and returns a dummy result. | ||
| client.register_tool_call_handler("get_weather", WeatherToolHandler()) | ||
| print("Registered WeatherToolHandler for 'get_weather'") | ||
|
|
||
| display = VideoDisplay() | ||
| audio_player = AudioPlayer() | ||
| audio_player.start() | ||
|
|
||
| import threading | ||
|
|
||
| loop = asyncio.new_event_loop() | ||
| asyncio.set_event_loop(loop) | ||
| stream_task = loop.create_task(stream_session(client, display, audio_player)) | ||
|
|
||
| def run_async() -> None: | ||
| try: | ||
| loop.run_until_complete(stream_task) | ||
| except asyncio.CancelledError: | ||
| pass | ||
| except Exception as e: | ||
| print(f"Error: {e}") | ||
|
|
||
| thread = threading.Thread(target=run_async, daemon=True) | ||
| thread.start() | ||
|
|
||
| try: | ||
| display.run() | ||
| except KeyboardInterrupt: | ||
| print("\nInterrupted") | ||
| finally: | ||
| display.stop() | ||
| audio_player.stop() | ||
| if not stream_task.done(): | ||
| stream_task.cancel() | ||
| thread.join(timeout=2.0) | ||
| if not loop.is_running(): | ||
| try: | ||
| pending = [t for t in asyncio.all_tasks(loop) if not t.done()] | ||
| for task in pending: | ||
| task.cancel() | ||
| if pending: | ||
| loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) | ||
| except RuntimeError: | ||
| pass | ||
| finally: | ||
| try: | ||
| if not loop.is_closed(): | ||
| loop.close() | ||
| except RuntimeError: | ||
| pass | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.