diff --git a/docs/integrations/llms/ag2.md b/docs/integrations/llms/ag2.md new file mode 100644 index 000000000..bb7608a6f --- /dev/null +++ b/docs/integrations/llms/ag2.md @@ -0,0 +1,95 @@ +--- +title: AG2 (Multi-Agent Framework) +description: Instrument AG2 multi-agent conversations with Logfire spans for conversation flow, agent turns, and tool execution. +integration: logfire +--- + +[AG2](https://docs.ag2.ai) supports multi-agent orchestration patterns such as two-agent chat and group chat. +Use [`logfire.instrument_ag2()`][logfire.Logfire.instrument_ag2] to trace conversation lifecycle events, agent turns, and tool execution. + +## What gets traced + +`logfire.instrument_ag2()` creates spans for: + +- **Conversation lifecycle** (`AG2 conversation`) +- **Group chat orchestration** (`AG2 group chat run`, `AG2 group chat round`) +- **Agent turns** (`AG2 agent turn`) +- **Tool execution** (`AG2 tool execution`) + +LLM provider request spans are still produced by provider-specific integrations (for example [`instrument_openai()`][logfire.Logfire.instrument_openai]), so AG2 spans stay focused on orchestration. + +## Install + +```bash +pip install logfire "ag2[openai]>=0.11.4,<1.0" +``` + +## Basic usage + +```python skip-run="true" skip-reason="external-connection" +import os + +from autogen import AssistantAgent, GroupChat, GroupChatManager, LLMConfig, UserProxyAgent + +import logfire + + +llm_config = LLMConfig( + { + 'model': 'gpt-4o-mini', + 'api_key': os.getenv('OPENAI_API_KEY'), + 'api_type': 'openai', + } +) + + +def is_termination(msg: dict[str, object]) -> bool: + content = msg.get('content', '') or '' + return isinstance(content, str) and 'TERMINATE' in content + + +proxy = UserProxyAgent( + name='user_proxy', + human_input_mode='NEVER', + max_consecutive_auto_reply=10, + code_execution_config=False, + is_termination_msg=is_termination, +) +research = AssistantAgent(name='research_agent', system_message='Find relevant facts.', llm_config=llm_config) +analyst = AssistantAgent( + name='analyst_agent', + system_message='Summarize the findings and say TERMINATE when done.', + llm_config=llm_config, +) + + +@proxy.register_for_execution() +@research.register_for_llm(description='Search for information') +def search_knowledge(query: str) -> str: + return f'Results for {query}' + + +group_chat = GroupChat(agents=[proxy, research, analyst], messages=[], max_round=10) +manager = GroupChatManager(groupchat=group_chat, llm_config=llm_config, is_termination_msg=is_termination) + +logfire.configure(send_to_logfire=False) +logfire.instrument_ag2(record_content=False) + +# Optional: also instrument OpenAI request spans +logfire.instrument_openai() + +proxy.run(manager, message='What is AG2?').process() +``` + +## Configuration + +`logfire.instrument_ag2()` supports: + +- `agent`: instrument a specific AG2 agent instance (or iterable of instances); if omitted, instrument globally. +- `record_content`: include message/tool payload content in span attributes. Defaults to `False`. +- `suppress_other_instrumentation`: suppress other OTEL instrumentation while AG2 conversation processing runs. + +## Related docs + +- [Logfire concepts](https://logfire.pydantic.dev/docs/concepts/) +- [AG2 documentation](https://docs.ag2.ai) diff --git a/examples/python/ag2-openai-quickstart/README.md b/examples/python/ag2-openai-quickstart/README.md new file mode 100644 index 000000000..f26a1aee1 --- /dev/null +++ b/examples/python/ag2-openai-quickstart/README.md @@ -0,0 +1,32 @@ +# AG2 + Logfire quickstart (real OpenAI calls) + +This example runs a real AG2 multi-agent conversation and emits Logfire spans. + +## Requirements + +- Python environment with this repository installed +- `ag2[openai]` package +- `OPENAI_API_KEY` +- Optional: `LOGFIRE_TOKEN` (if you want to send traces to cloud) + +## Run + +```bash +python examples/python/ag2-openai-quickstart/main.py +``` + +Optional arguments: + +```bash +python examples/python/ag2-openai-quickstart/main.py --model gpt-4o-mini --question "Plan a 2-day trip to Samarkand" --max-round 6 +``` + +## Environment variables + +```bash +export OPENAI_API_KEY="" +# Optional cloud export: +export LOGFIRE_TOKEN="" +``` + +If `LOGFIRE_TOKEN` is not set, spans are still created locally with `send_to_logfire=False`. diff --git a/examples/python/ag2-openai-quickstart/main.py b/examples/python/ag2-openai-quickstart/main.py new file mode 100644 index 000000000..50230a2d5 --- /dev/null +++ b/examples/python/ag2-openai-quickstart/main.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import argparse +import os + +from autogen import AssistantAgent, GroupChat, GroupChatManager, LLMConfig, UserProxyAgent + +import logfire + + +def parse_args() -> argparse.Namespace: + """Parse CLI arguments for the AG2 quickstart runner.""" + parser = argparse.ArgumentParser(description='AG2 + Logfire quickstart with real OpenAI calls') + parser.add_argument( + '--model', + default='gpt-4o-mini', + help='OpenAI model name (default: gpt-4o-mini)', + ) + parser.add_argument( + '--question', + default='Briefly explain what AG2 is and why multi-agent orchestration helps in practice.', + help='Task to run through AG2 group chat', + ) + parser.add_argument( + '--max-round', + type=int, + default=6, + help='Maximum group-chat rounds (default: 6)', + ) + return parser.parse_args() + + +def is_termination(msg: dict[str, object]) -> bool: + """Return True when a message indicates the AG2 chat should terminate.""" + content = msg.get('content', '') or '' + return isinstance(content, str) and 'TERMINATE' in content + + +def _extract_final_message(messages: list[dict[str, object]]) -> str | None: + for message in reversed(messages): + content = message.get('content') + if not isinstance(content, str) or not content.strip(): + continue + + role = message.get('role') + if role == 'assistant' or 'TERMINATE' in content: + return content + + return None + + +def main() -> None: + """Run a real AG2 group chat with OpenAI and emit Logfire spans.""" + args = parse_args() + + openai_api_key = os.getenv('OPENAI_API_KEY') + if not openai_api_key: + raise SystemExit('OPENAI_API_KEY is required to run real OpenAI calls.') + + llm_config = LLMConfig( + { + 'model': args.model, + 'api_key': openai_api_key, + 'api_type': 'openai', + } + ) + + send_to_logfire = bool(os.getenv('LOGFIRE_TOKEN')) + logfire.configure(send_to_logfire=send_to_logfire) + + proxy = UserProxyAgent( + name='user_proxy', + human_input_mode='NEVER', + max_consecutive_auto_reply=10, + code_execution_config=False, + is_termination_msg=is_termination, + ) + researcher = AssistantAgent( + name='researcher', + system_message='You gather short factual context. Keep it compact.', + llm_config=llm_config, + ) + summarizer = AssistantAgent( + name='summarizer', + system_message='You summarize the answer clearly and finish with TERMINATE.', + llm_config=llm_config, + ) + + @proxy.register_for_execution() + @researcher.register_for_llm(description='Lookup short synthetic facts') + def quick_lookup(topic: str) -> str: + return f'Quick lookup result about: {topic}' + + group_chat = GroupChat(agents=[proxy, researcher, summarizer], messages=[], max_round=args.max_round) + manager = GroupChatManager(groupchat=group_chat, llm_config=llm_config, is_termination_msg=is_termination) + + with logfire.instrument_ag2(record_content=False): + # Optional but recommended for provider-level spans. + logfire.instrument_openai() + print(f'Running AG2 chat with model={args.model!r} ...') + response = proxy.run(manager, message=args.question) + final_output = response.process() + + if final_output is None: + typed_messages: list[dict[str, object]] = list(manager.groupchat.messages) + final_output = _extract_final_message(typed_messages) + + print('\n=== Final output ===\n') + print(final_output if final_output is not None else '(No final content returned by AG2 response.process())') + print('\nDone. Open Logfire to inspect AG2 conversation, rounds, turns, and tool spans.') + + +if __name__ == '__main__': + main() diff --git a/logfire-api/logfire_api/__init__.py b/logfire-api/logfire_api/__init__.py index 855c35c6d..fc81c570a 100644 --- a/logfire-api/logfire_api/__init__.py +++ b/logfire-api/logfire_api/__init__.py @@ -150,6 +150,9 @@ def instrument_pydantic(self, *args, **kwargs) -> None: ... def instrument_pydantic_ai(self, *args, **kwargs) -> None: ... + def instrument_ag2(self, *args, **kwargs) -> ContextManager[None]: + return nullcontext() + def instrument_pymongo(self, *args, **kwargs) -> None: ... def instrument_sqlalchemy(self, *args, **kwargs) -> None: ... @@ -231,6 +234,7 @@ def shutdown(self, *args, **kwargs) -> None: ... instrument_wsgi = DEFAULT_LOGFIRE_INSTANCE.instrument_wsgi instrument_pydantic = DEFAULT_LOGFIRE_INSTANCE.instrument_pydantic instrument_pydantic_ai = DEFAULT_LOGFIRE_INSTANCE.instrument_pydantic_ai + instrument_ag2 = DEFAULT_LOGFIRE_INSTANCE.instrument_ag2 instrument_fastapi = DEFAULT_LOGFIRE_INSTANCE.instrument_fastapi instrument_openai = DEFAULT_LOGFIRE_INSTANCE.instrument_openai instrument_openai_agents = DEFAULT_LOGFIRE_INSTANCE.instrument_openai_agents diff --git a/logfire-api/logfire_api/__init__.pyi b/logfire-api/logfire_api/__init__.pyi index e73789536..00e710923 100644 --- a/logfire-api/logfire_api/__init__.pyi +++ b/logfire-api/logfire_api/__init__.pyi @@ -16,7 +16,7 @@ from logfire.propagate import attach_context as attach_context, get_context as g from logfire.sampling import SamplingOptions as SamplingOptions from typing import Any -__all__ = ['Logfire', 'LogfireSpan', 'LevelName', 'AdvancedOptions', 'ConsoleOptions', 'CodeSource', 'PydanticPlugin', 'configure', 'span', 'instrument', 'log', 'trace', 'debug', 'notice', 'info', 'warn', 'warning', 'error', 'exception', 'fatal', 'force_flush', 'log_slow_async_callbacks', 'install_auto_tracing', 'instrument_asgi', 'instrument_wsgi', 'instrument_pydantic', 'instrument_pydantic_ai', 'instrument_fastapi', 'instrument_openai', 'instrument_openai_agents', 'instrument_anthropic', 'instrument_google_genai', 'instrument_litellm', 'instrument_dspy', 'instrument_print', 'instrument_asyncpg', 'instrument_httpx', 'instrument_celery', 'instrument_requests', 'instrument_psycopg', 'instrument_django', 'instrument_flask', 'instrument_starlette', 'instrument_aiohttp_client', 'instrument_aiohttp_server', 'instrument_sqlalchemy', 'instrument_sqlite3', 'instrument_aws_lambda', 'instrument_redis', 'instrument_pymongo', 'instrument_mysql', 'instrument_surrealdb', 'instrument_system_metrics', 'instrument_mcp', 'instrument_claude_agent_sdk', 'AutoTraceModule', 'with_tags', 'with_settings', 'suppress_scopes', 'shutdown', 'no_auto_trace', 'ScrubMatch', 'ScrubbingOptions', 'VERSION', 'add_non_user_code_prefix', 'suppress_instrumentation', 'StructlogProcessor', 'LogfireLoggingHandler', 'loguru_handler', 'SamplingOptions', 'MetricsOptions', 'VariablesOptions', 'LocalVariablesOptions', 'variables', 'var', 'variables_clear', 'variables_get', 'variables_push', 'variables_push_types', 'variables_validate', 'variables_push_config', 'variables_pull_config', 'variables_build_config', 'logfire_info', 'get_baggage', 'set_baggage', 'get_context', 'attach_context', 'url_from_eval'] +__all__ = ['Logfire', 'LogfireSpan', 'LevelName', 'AdvancedOptions', 'ConsoleOptions', 'CodeSource', 'PydanticPlugin', 'configure', 'span', 'instrument', 'log', 'trace', 'debug', 'notice', 'info', 'warn', 'warning', 'error', 'exception', 'fatal', 'force_flush', 'log_slow_async_callbacks', 'install_auto_tracing', 'instrument_asgi', 'instrument_wsgi', 'instrument_pydantic', 'instrument_pydantic_ai', 'instrument_ag2', 'instrument_fastapi', 'instrument_openai', 'instrument_openai_agents', 'instrument_anthropic', 'instrument_google_genai', 'instrument_litellm', 'instrument_dspy', 'instrument_print', 'instrument_asyncpg', 'instrument_httpx', 'instrument_celery', 'instrument_requests', 'instrument_psycopg', 'instrument_django', 'instrument_flask', 'instrument_starlette', 'instrument_aiohttp_client', 'instrument_aiohttp_server', 'instrument_sqlalchemy', 'instrument_sqlite3', 'instrument_aws_lambda', 'instrument_redis', 'instrument_pymongo', 'instrument_mysql', 'instrument_surrealdb', 'instrument_system_metrics', 'instrument_mcp', 'instrument_claude_agent_sdk', 'AutoTraceModule', 'with_tags', 'with_settings', 'suppress_scopes', 'shutdown', 'no_auto_trace', 'ScrubMatch', 'ScrubbingOptions', 'VERSION', 'add_non_user_code_prefix', 'suppress_instrumentation', 'StructlogProcessor', 'LogfireLoggingHandler', 'loguru_handler', 'SamplingOptions', 'MetricsOptions', 'VariablesOptions', 'LocalVariablesOptions', 'variables', 'var', 'variables_clear', 'variables_get', 'variables_push', 'variables_push_types', 'variables_validate', 'variables_push_config', 'variables_pull_config', 'variables_build_config', 'logfire_info', 'get_baggage', 'set_baggage', 'get_context', 'attach_context', 'url_from_eval'] DEFAULT_LOGFIRE_INSTANCE = Logfire() span = DEFAULT_LOGFIRE_INSTANCE.span @@ -26,6 +26,7 @@ log_slow_async_callbacks = DEFAULT_LOGFIRE_INSTANCE.log_slow_async_callbacks install_auto_tracing = DEFAULT_LOGFIRE_INSTANCE.install_auto_tracing instrument_pydantic = DEFAULT_LOGFIRE_INSTANCE.instrument_pydantic instrument_pydantic_ai = DEFAULT_LOGFIRE_INSTANCE.instrument_pydantic_ai +instrument_ag2 = DEFAULT_LOGFIRE_INSTANCE.instrument_ag2 instrument_asgi = DEFAULT_LOGFIRE_INSTANCE.instrument_asgi instrument_wsgi = DEFAULT_LOGFIRE_INSTANCE.instrument_wsgi instrument_fastapi = DEFAULT_LOGFIRE_INSTANCE.instrument_fastapi diff --git a/logfire-api/logfire_api/_internal/main.pyi b/logfire-api/logfire_api/_internal/main.pyi index bffddfadc..a074d91a2 100644 --- a/logfire-api/logfire_api/_internal/main.pyi +++ b/logfire-api/logfire_api/_internal/main.pyi @@ -478,6 +478,8 @@ class Logfire: def instrument_pydantic_ai(self, obj: pydantic_ai.Agent | None = None, /, *, include_binary_content: bool | None = None, include_content: bool | None = None, version: Literal[1, 2, 3] | None = None, event_mode: Literal['attributes', 'logs'] | None = None, **kwargs: Any) -> None: ... @overload def instrument_pydantic_ai(self, obj: pydantic_ai.models.Model, /, *, include_binary_content: bool | None = None, include_content: bool | None = None, version: Literal[1, 2, 3] | None = None, event_mode: Literal['attributes', 'logs'] | None = None, **kwargs: Any) -> pydantic_ai.models.Model: ... + def instrument_ag2(self, agent: Any | Iterable[Any] | None = None, *, record_content: bool = False, suppress_other_instrumentation: bool = False) -> AbstractContextManager[None]: + """Instrument AG2 conversations, turns, and tool executions.""" def instrument_fastapi(self, app: FastAPI, *, capture_headers: bool = False, request_attributes_mapper: Callable[[Request | WebSocket, dict[str, Any]], dict[str, Any] | None] | None = None, excluded_urls: str | Iterable[str] | None = None, record_send_receive: bool = False, extra_spans: bool = False, **opentelemetry_kwargs: Any) -> AbstractContextManager[None]: """Instrument a FastAPI app so that spans and logs are automatically created for each request. diff --git a/logfire/__init__.py b/logfire/__init__.py index fb03376ea..9bceeef8c 100644 --- a/logfire/__init__.py +++ b/logfire/__init__.py @@ -39,6 +39,7 @@ install_auto_tracing = DEFAULT_LOGFIRE_INSTANCE.install_auto_tracing instrument_pydantic = DEFAULT_LOGFIRE_INSTANCE.instrument_pydantic instrument_pydantic_ai = DEFAULT_LOGFIRE_INSTANCE.instrument_pydantic_ai +instrument_ag2 = DEFAULT_LOGFIRE_INSTANCE.instrument_ag2 instrument_asgi = DEFAULT_LOGFIRE_INSTANCE.instrument_asgi instrument_wsgi = DEFAULT_LOGFIRE_INSTANCE.instrument_wsgi instrument_fastapi = DEFAULT_LOGFIRE_INSTANCE.instrument_fastapi @@ -150,6 +151,7 @@ def loguru_handler() -> Any: 'instrument_wsgi', 'instrument_pydantic', 'instrument_pydantic_ai', + 'instrument_ag2', 'instrument_fastapi', 'instrument_openai', 'instrument_openai_agents', diff --git a/logfire/_internal/integrations/ag2.py b/logfire/_internal/integrations/ag2.py new file mode 100644 index 000000000..85b51e6c9 --- /dev/null +++ b/logfire/_internal/integrations/ag2.py @@ -0,0 +1,434 @@ +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable, Iterable +from contextlib import AbstractContextManager, contextmanager, nullcontext +from functools import wraps +from typing import TYPE_CHECKING, Any, cast + +from logfire import Logfire +from logfire._internal.utils import suppress_instrumentation + +if TYPE_CHECKING: # pragma: no cover + from autogen import ConversableAgent + +try: + import autogen +except ImportError: # pragma: no cover + raise RuntimeError( + '`logfire.instrument_ag2()` requires the `ag2` package.\n' + 'You can install this with:\n' + " pip install 'ag2[openai]>=0.11.4,<1.0'" + ) + +SPAN_CONVERSATION = 'AG2 conversation' +SPAN_GROUPCHAT = 'AG2 group chat run' +SPAN_GROUPCHAT_ROUND = 'AG2 group chat round' +SPAN_AGENT_TURN = 'AG2 agent turn' +SPAN_TOOL_EXECUTION = 'AG2 tool execution' + + +def instrument_ag2( + logfire_instance: Logfire, + agent: ConversableAgent | Iterable[ConversableAgent] | None = None, + *, + record_content: bool = False, + suppress_other_instrumentation: bool = False, +) -> AbstractContextManager[None]: + """Instrument AG2 conversations, agent turns, and tool execution. + + See ``Logfire.instrument_ag2`` for full documentation. + """ + target_ids = _target_agent_ids(agent) + originals: list[tuple[object, str, Any]] = [] + + def should_trace(agent_obj: Any) -> bool: + return target_ids is None or id(agent_obj) in target_ids + + def patch( + cls_or_obj: object, method_name: str, wrapper_factory: Callable[[Callable[..., Any]], Callable[..., Any]] + ) -> None: + original = getattr(cls_or_obj, method_name, None) + if original is None: + return + wrapped = wrapper_factory(original) + setattr(cls_or_obj, method_name, wrapped) + originals.append((cls_or_obj, method_name, original)) + + def wrap_run(original: Callable[..., Any]) -> Callable[..., Any]: + @wraps(original) + def _wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: + response = original(self, *args, **kwargs) + if not should_trace(self): + return response + _wrap_response_process( + response=response, + logfire_instance=logfire_instance, + runner=self, + recipient=_resolve_recipient(args, kwargs), + message=kwargs.get('message'), + record_content=record_content, + suppress_other_instrumentation=suppress_other_instrumentation, + ) + return response + + return _wrapped + + def wrap_a_run(original: Callable[..., Any]) -> Callable[..., Any]: + @wraps(original) + async def _wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: + response = await original(self, *args, **kwargs) + if not should_trace(self): + return response + _wrap_response_process( + response=response, + logfire_instance=logfire_instance, + runner=self, + recipient=_resolve_recipient(args, kwargs), + message=kwargs.get('message'), + record_content=record_content, + suppress_other_instrumentation=suppress_other_instrumentation, + ) + return response + + return _wrapped + + def wrap_generate_reply(original: Callable[..., Any]) -> Callable[..., Any]: + # generate_reply(self, messages=None, sender=None, exclude=()) + @wraps(original) + def _wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: + messages = args[0] if len(args) > 0 else kwargs.get('messages') + sender = args[1] if len(args) > 1 else kwargs.get('sender') + attrs = _agent_turn_attrs(self, sender, messages, record_content) + with logfire_instance.span(SPAN_AGENT_TURN, **attrs): + return original(self, *args, **kwargs) + + return _wrapped + + def wrap_a_generate_reply(original: Callable[..., Any]) -> Callable[..., Any]: + # a_generate_reply(self, messages=None, sender=None, exclude=()) + @wraps(original) + async def _wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: + messages = args[0] if len(args) > 0 else kwargs.get('messages') + sender = args[1] if len(args) > 1 else kwargs.get('sender') + attrs = _agent_turn_attrs(self, sender, messages, record_content) + with logfire_instance.span(SPAN_AGENT_TURN, **attrs): + return await original(self, *args, **kwargs) + + return _wrapped + + def wrap_execute_function(original: Callable[..., Any]) -> Callable[..., Any]: + # execute_function(self, func_call, call_id=None, verbose=False) + @wraps(original) + def _wrapped(self: Any, func_call: dict[str, Any], *args: Any, **kwargs: Any) -> Any: + call_id = args[0] if len(args) > 0 else kwargs.get('call_id') + attrs = _tool_call_attrs(self, func_call, call_id, record_content) + with logfire_instance.span(SPAN_TOOL_EXECUTION, **attrs) as span: + result = original(self, func_call, *args, **kwargs) + _set_tool_result_attributes(span, result, record_content) + return result + + return _wrapped + + def wrap_a_execute_function(original: Callable[..., Any]) -> Callable[..., Any]: + # a_execute_function(self, func_call, call_id=None, verbose=False) + @wraps(original) + async def _wrapped(self: Any, func_call: dict[str, Any], *args: Any, **kwargs: Any) -> Any: + call_id = args[0] if len(args) > 0 else kwargs.get('call_id') + attrs = _tool_call_attrs(self, func_call, call_id, record_content) + with logfire_instance.span(SPAN_TOOL_EXECUTION, **attrs) as span: + result = await original(self, func_call, *args, **kwargs) + _set_tool_result_attributes(span, result, record_content) + return result + + return _wrapped + + def wrap_run_chat(original: Callable[..., Any]) -> Callable[..., Any]: + # run_chat(self, messages=None, sender=None, config=None) + @wraps(original) + def _wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: + groupchat = args[2] if len(args) > 2 else kwargs.get('config') + attrs = { + 'ag2.manager_name': getattr(self, 'name', type(self).__name__), + 'ag2.max_round': getattr(groupchat, 'max_round', None), + } + attrs = {k: v for k, v in attrs.items() if v is not None} + with logfire_instance.span(SPAN_GROUPCHAT, **attrs) as span: # pyright: ignore[reportArgumentType] + result = original(self, *args, **kwargs) + _set_groupchat_summary_attributes(span, groupchat, self) + return result + + return _wrapped + + def wrap_a_run_chat(original: Callable[..., Any]) -> Callable[..., Any]: + # a_run_chat(self, messages=None, sender=None, config=None) + @wraps(original) + async def _wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: + groupchat = args[2] if len(args) > 2 else kwargs.get('config') + attrs = { + 'ag2.manager_name': getattr(self, 'name', type(self).__name__), + 'ag2.max_round': getattr(groupchat, 'max_round', None), + } + attrs = {k: v for k, v in attrs.items() if v is not None} + with logfire_instance.span(SPAN_GROUPCHAT, **attrs) as span: # pyright: ignore[reportArgumentType] + result = await original(self, *args, **kwargs) + _set_groupchat_summary_attributes(span, groupchat, self) + return result + + return _wrapped + + def wrap_select_speaker(original: Callable[..., Any]) -> Callable[..., Any]: + @wraps(original) + def _wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: + attrs = { + 'ag2.round_number': len(getattr(self, 'messages', []) or []), + 'ag2.last_speaker': getattr(args[0], 'name', None) if args else None, + } + attrs = {k: v for k, v in attrs.items() if v is not None} + with logfire_instance.span(SPAN_GROUPCHAT_ROUND, **attrs) as span: # pyright: ignore[reportArgumentType] + selected = original(self, *args, **kwargs) + span.set_attribute('ag2.next_speaker', getattr(selected, 'name', type(selected).__name__)) + return selected + + return _wrapped + + def wrap_a_select_speaker(original: Callable[..., Any]) -> Callable[..., Any]: + @wraps(original) + async def _wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: + attrs = { + 'ag2.round_number': len(getattr(self, 'messages', []) or []), + 'ag2.last_speaker': getattr(args[0], 'name', None) if args else None, + } + attrs = {k: v for k, v in attrs.items() if v is not None} + with logfire_instance.span(SPAN_GROUPCHAT_ROUND, **attrs) as span: # pyright: ignore[reportArgumentType] + selected = await original(self, *args, **kwargs) + span.set_attribute('ag2.next_speaker', getattr(selected, 'name', type(selected).__name__)) + return selected + + return _wrapped + + patch(autogen.ConversableAgent, 'run', wrap_run) + patch(autogen.ConversableAgent, 'a_run', wrap_a_run) + patch(autogen.ConversableAgent, 'generate_reply', wrap_generate_reply) + patch(autogen.ConversableAgent, 'a_generate_reply', wrap_a_generate_reply) + patch(autogen.ConversableAgent, 'execute_function', wrap_execute_function) + patch(autogen.ConversableAgent, 'a_execute_function', wrap_a_execute_function) + + patch(autogen.GroupChatManager, 'run_chat', wrap_run_chat) + patch(autogen.GroupChatManager, 'a_run_chat', wrap_a_run_chat) + + patch(autogen.GroupChat, 'select_speaker', wrap_select_speaker) + patch(autogen.GroupChat, 'a_select_speaker', wrap_a_select_speaker) + + @contextmanager + def uninstrument_context(): + # The user isn't required (or even expected) to use this context manager, + # which is why the instrumenting and patching has already happened before this point. + # It exists mostly for tests, and just in case users want it. + try: + yield + finally: + for cls_or_obj, method_name, orig in reversed(originals): + setattr(cls_or_obj, method_name, orig) + + return uninstrument_context() + + +def _wrap_response_process( + *, + response: Any, + logfire_instance: Logfire, + runner: Any, + recipient: Any, + message: Any, + record_content: bool, + suppress_other_instrumentation: bool, +) -> None: + process = getattr(response, 'process', None) + if process is None: + return + + if getattr(response, '_logfire_ag2_process_wrapped', False): + return + + wrapped: Callable[..., Any] + + if inspect.iscoroutinefunction(process): + + @wraps(process) + async def wrapped_process_async(*args: Any, **kwargs: Any) -> Any: + attrs = _conversation_attrs(runner, recipient, message, record_content) + with logfire_instance.span(SPAN_CONVERSATION, **attrs) as span: + cm = suppress_instrumentation() if suppress_other_instrumentation else nullcontext() + with cm: + result = await process(*args, **kwargs) + _set_conversation_summary_attributes(span, recipient) + return result + + wrapped = wrapped_process_async + + else: + + @wraps(process) + def wrapped_process_sync(*args: Any, **kwargs: Any) -> Any: + attrs = _conversation_attrs(runner, recipient, message, record_content) + with logfire_instance.span(SPAN_CONVERSATION, **attrs) as span: + cm = suppress_instrumentation() if suppress_other_instrumentation else nullcontext() + with cm: + result = process(*args, **kwargs) + _set_conversation_summary_attributes(span, recipient) + return result + + wrapped = wrapped_process_sync + + setattr(response, 'process', wrapped) + setattr(response, '_logfire_ag2_process_wrapped', True) + + +def _target_agent_ids(agent: ConversableAgent | Iterable[ConversableAgent] | None) -> set[int] | None: + if agent is None: + return None + if isinstance(agent, autogen.ConversableAgent): + return {id(agent)} + return {id(a) for a in agent} + + +def _resolve_recipient(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any | None: + if 'recipient' in kwargs: + return kwargs['recipient'] + if args: + return args[0] + return None + + +def _conversation_attrs(runner: Any, recipient: Any, message: Any, record_content: bool) -> dict[str, Any]: + attrs: dict[str, Any] = { + 'ag2.runner_name': getattr(runner, 'name', type(runner).__name__), + 'ag2.recipient_name': getattr(recipient, 'name', type(recipient).__name__) if recipient is not None else None, + } + if record_content and message is not None: + attrs['ag2.user_message'] = _safe_content(message) + return {k: v for k, v in attrs.items() if v is not None} + + +def _agent_turn_attrs(agent: Any, sender: Any, messages: Any, record_content: bool) -> dict[str, Any]: + attrs: dict[str, Any] = { + 'ag2.agent_name': getattr(agent, 'name', type(agent).__name__), + 'ag2.sender_name': getattr(sender, 'name', type(sender).__name__) if sender is not None else None, + } + last_message = _last_message(messages) + if isinstance(last_message, dict): + role = last_message.get('role') + if role is not None: + attrs['ag2.message_role'] = role + if record_content and 'content' in last_message: + attrs['ag2.message_content'] = _safe_content(last_message.get('content')) + return {k: v for k, v in attrs.items() if v is not None} + + +def _tool_call_attrs(agent: Any, func_call: dict[str, Any], call_id: Any, record_content: bool) -> dict[str, Any]: + attrs: dict[str, Any] = { + 'ag2.agent_name': getattr(agent, 'name', type(agent).__name__), + 'ag2.tool_name': func_call.get('name', ''), + 'ag2.call_id': call_id, + } + parsed_args = _parse_tool_arguments(func_call.get('arguments')) + attrs['ag2.tool_arg_names'] = sorted(parsed_args) + if record_content: + attrs['ag2.tool_args'] = parsed_args + return {k: v for k, v in attrs.items() if v is not None} + + +def _parse_tool_arguments(arguments: Any) -> dict[str, Any]: + if isinstance(arguments, dict): + return cast(dict[str, Any], arguments) + if isinstance(arguments, str): + try: + parsed = json.loads(arguments) + if isinstance(parsed, dict): + return cast(dict[str, Any], parsed) + except Exception: + return {} + return {} + + +def _set_tool_result_attributes(span: Any, result: Any, record_content: bool) -> None: + tuple_result = cast(tuple[Any, ...], result) + if not isinstance(result, tuple) or len(tuple_result) != 2: + return + success, payload = cast(tuple[Any, Any], result) + span.set_attribute('ag2.execution.success', bool(success)) + if record_content and isinstance(payload, dict): + payload_dict = cast(dict[str, Any], payload) + span.set_attribute('ag2.tool_result', _safe_content(payload_dict.get('content'))) + + +def _set_groupchat_summary_attributes(span: Any, groupchat: Any, manager: Any) -> None: + messages = getattr(groupchat, 'messages', None) + if isinstance(messages, list) and all(isinstance(m, dict) for m in cast(list[Any], messages)): + typed_messages = cast(list[dict[str, Any]], messages) + span.set_attribute('ag2.groupchat.message_count', len(typed_messages)) + if typed_messages: + last_message = typed_messages[-1] + content = last_message.get('content', '') + span.set_attribute('ag2.groupchat.last_message_role', last_message.get('role', 'unknown')) + if isinstance(content, str): + span.set_attribute('ag2.groupchat.terminated', 'TERMINATE' in content) + if ( + isinstance(messages, list) + and messages + and hasattr(manager, '_is_termination_msg') + and callable(manager._is_termination_msg) + ): + try: + span.set_attribute('ag2.groupchat.is_termination', bool(manager._is_termination_msg(messages[-1]))) + except Exception: + pass + + +def _set_conversation_summary_attributes(span: Any, recipient: Any) -> None: + groupchat = getattr(recipient, 'groupchat', None) + if groupchat is None: + return + messages = getattr(groupchat, 'messages', None) + if not (isinstance(messages, list) and all(isinstance(m, dict) for m in cast(list[Any], messages))): + return + typed_messages = cast(list[dict[str, Any]], messages) + span.set_attribute('ag2.total_messages', len(typed_messages)) + if typed_messages: + rounds = _count_rounds(typed_messages) + span.set_attribute('ag2.total_rounds', rounds) + last = typed_messages[-1] + role = last.get('role') + if role is not None: + span.set_attribute('ag2.last_role', role) + content = last.get('content') + if isinstance(content, str): + span.set_attribute('ag2.termination_reason', 'TERMINATE' if 'TERMINATE' in content else 'unknown') + + +def _count_rounds(messages: list[dict[str, Any]]) -> int: + """Count rounds as speaker transitions (consecutive messages from different 'name' fields).""" + rounds = 1 if messages else 0 + for i in range(1, len(messages)): + if messages[i].get('name') != messages[i - 1].get('name'): + rounds += 1 + return rounds + + +def _last_message(messages: Any) -> dict[str, Any] | None: + if isinstance(messages, list) and messages and isinstance(messages[-1], dict): + return cast(dict[str, Any], messages[-1]) + return None + + +def _safe_content(value: Any) -> str: + if value is None: + return '' + if isinstance(value, str): + return value + try: + return json.dumps(value, default=str) + except Exception: + return str(value) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 00b422667..c89a1f66d 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -75,6 +75,7 @@ from wsgiref.types import WSGIApplication import anthropic + import autogen import httpx import openai import pydantic_ai.models @@ -1147,6 +1148,40 @@ def instrument_pydantic_ai( **kwargs, ) + def instrument_ag2( + self, + agent: autogen.ConversableAgent | Iterable[autogen.ConversableAgent] | None = None, + *, + record_content: bool = False, + suppress_other_instrumentation: bool = False, + ) -> AbstractContextManager[None]: + """Instrument AG2 conversations, turns, and tool executions. + + Args: + agent: Optional AG2 agent instance (or iterable of agents) to scope which conversations + are traced. When provided, only conversation-level spans are filtered to the given + agent(s); inner spans (agent turns, tool executions, group chat rounds) are still + emitted for all participants within a traced conversation. + If omitted, instrumentation is applied globally to all agents. + record_content: If `True`, include message and tool payload content in span attributes. + Defaults to `False` to avoid recording potentially sensitive data. + suppress_other_instrumentation: If `True`, suppress other OTEL instrumentation while AG2 + conversation processing runs. + + Returns: + A context manager that reverts the instrumentation when exited. + Use of this context manager is optional. + """ + from .integrations.ag2 import instrument_ag2 + + self._warn_if_not_initialized_for_instrumentation() + return instrument_ag2( + self, + agent=agent, + record_content=record_content, + suppress_other_instrumentation=suppress_other_instrumentation, + ) + def instrument_fastapi( self, app: FastAPI, diff --git a/mkdocs.yml b/mkdocs.yml index 7879e91a0..41a200abc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -124,6 +124,7 @@ nav: - Integrations: integrations/index.md - AI & LLM Frameworks: - Pydantic AI: integrations/llms/pydanticai.md + - AG2: integrations/llms/ag2.md - OpenAI: integrations/llms/openai.md - Google Gen AI: integrations/llms/google-genai.md - Anthropic: integrations/llms/anthropic.md diff --git a/pyproject.toml b/pyproject.toml index 0b71b7fe5..3ec8bbc4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -198,6 +198,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-asyncio>=0.24.0", "claude-agent-sdk>=0 ; python_full_version >= '3.10'", + "ag2[openai]>=0.11.4,<1.0; python_version >= '3.10'", ] docs = [ "black>=23.12.0", diff --git a/tests/test_ag2.py b/tests/test_ag2.py new file mode 100644 index 000000000..b7a8c65e2 --- /dev/null +++ b/tests/test_ag2.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from typing import Any, cast + +from inline_snapshot import snapshot + +import logfire +from logfire.testing import TestExporter + +warnings.filterwarnings( + 'ignore', + message='jsonschema.RefResolver is deprecated.*', + category=DeprecationWarning, +) +warnings.filterwarnings( + 'ignore', + message='Accessing jsonschema.__version__ is deprecated.*', + category=DeprecationWarning, +) + +try: + import autogen as _autogen +except ImportError: # pragma: no cover + _autogen = None + +autogen: Any = cast(Any, _autogen) + + +@dataclass +class _DummyRunResponse: + callback: Any + + def process(self, processor: Any = None) -> None: + self.callback() + + +def _new_user_proxy(name: str) -> Any: + return autogen.UserProxyAgent( + name=name, + human_input_mode='NEVER', + max_consecutive_auto_reply=10, + code_execution_config=False, + ) + + +def test_instrument_ag2_conversation_groupchat_and_turn_spans( + exporter: TestExporter, + monkeypatch: Any, +) -> None: + if autogen is None: # pragma: no cover + return + + proxy = _new_user_proxy('user_proxy') + assistant = _new_user_proxy('assistant_agent') + + group_chat = autogen.GroupChat(agents=[proxy, assistant], messages=[], max_round=5) + manager = autogen.GroupChatManager(groupchat=group_chat, llm_config=False) + + def fake_select_speaker(self: Any, last_speaker: Any, selector: Any) -> Any: + return assistant + + def fake_generate_reply( + self: Any, messages: list[dict[str, Any]] | None = None, sender: Any = None, exclude: Any = () + ) -> dict[str, Any]: + return {'role': 'assistant', 'content': 'TERMINATE'} + + def fake_run_chat( + self: Any, messages: list[dict[str, Any]] | None = None, sender: Any = None, config: Any = None + ) -> tuple[bool, None]: + group_chat.append({'role': 'user', 'content': 'What is AG2?'}, proxy) + _ = group_chat.select_speaker(proxy, self) + _ = assistant.generate_reply(messages=[{'role': 'user', 'content': 'What is AG2?'}], sender=proxy) + group_chat.append({'role': 'assistant', 'content': 'TERMINATE'}, assistant) + return True, None + + def fake_run(self: Any, recipient: Any = None, **kwargs: Any) -> _DummyRunResponse: + def _process() -> None: + recipient.run_chat( + messages=[{'role': 'user', 'content': kwargs.get('message', '')}], + sender=self, + config=recipient.groupchat, + ) + + return _DummyRunResponse(callback=_process) + + monkeypatch.setattr(autogen.GroupChat, 'select_speaker', fake_select_speaker) + monkeypatch.setattr(autogen.ConversableAgent, 'generate_reply', fake_generate_reply) + monkeypatch.setattr(autogen.GroupChatManager, 'run_chat', fake_run_chat) + monkeypatch.setattr(autogen.ConversableAgent, 'run', fake_run) + + with logfire.instrument_ag2(record_content=True): + proxy.run(manager, message='What is AG2?').process() + + assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( + [ + { + 'name': 'AG2 group chat round', + 'context': {'trace_id': 1, 'span_id': 5, 'is_remote': False}, + 'parent': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, + 'start_time': 3000000000, + 'end_time': 4000000000, + 'attributes': { + 'code.filepath': 'test_ag2.py', + 'code.function': 'fake_run_chat', + 'code.lineno': 123, + 'ag2.round_number': 1, + 'ag2.last_speaker': 'user_proxy', + 'logfire.msg_template': 'AG2 group chat round', + 'logfire.msg': 'AG2 group chat round', + 'logfire.span_type': 'span', + 'ag2.next_speaker': 'assistant_agent', + 'logfire.json_schema': { + 'type': 'object', + 'properties': {'ag2.round_number': {}, 'ag2.last_speaker': {}, 'ag2.next_speaker': {}}, + }, + }, + }, + { + 'name': 'AG2 agent turn', + 'context': {'trace_id': 1, 'span_id': 7, 'is_remote': False}, + 'parent': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, + 'start_time': 5000000000, + 'end_time': 6000000000, + 'attributes': { + 'code.filepath': 'test_ag2.py', + 'code.function': 'fake_run_chat', + 'code.lineno': 123, + 'ag2.agent_name': 'assistant_agent', + 'ag2.sender_name': 'user_proxy', + 'ag2.message_role': 'user', + 'ag2.message_content': 'What is AG2?', + 'logfire.msg_template': 'AG2 agent turn', + 'logfire.msg': 'AG2 agent turn', + 'logfire.json_schema': { + 'type': 'object', + 'properties': { + 'ag2.agent_name': {}, + 'ag2.sender_name': {}, + 'ag2.message_role': {}, + 'ag2.message_content': {}, + }, + }, + 'logfire.span_type': 'span', + }, + }, + { + 'name': 'AG2 group chat run', + 'context': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, + 'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'start_time': 2000000000, + 'end_time': 7000000000, + 'attributes': { + 'code.filepath': 'test_ag2.py', + 'code.function': '_process', + 'code.lineno': 123, + 'ag2.manager_name': 'chat_manager', + 'ag2.max_round': 5, + 'logfire.msg_template': 'AG2 group chat run', + 'logfire.msg': 'AG2 group chat run', + 'logfire.span_type': 'span', + 'ag2.groupchat.message_count': 2, + 'ag2.groupchat.last_message_role': 'assistant', + 'ag2.groupchat.terminated': True, + 'ag2.groupchat.is_termination': True, + 'logfire.json_schema': { + 'type': 'object', + 'properties': { + 'ag2.manager_name': {}, + 'ag2.max_round': {}, + 'ag2.groupchat.message_count': {}, + 'ag2.groupchat.last_message_role': {}, + 'ag2.groupchat.terminated': {}, + 'ag2.groupchat.is_termination': {}, + }, + }, + }, + }, + { + 'name': 'AG2 conversation', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 8000000000, + 'attributes': { + 'code.filepath': 'test_ag2.py', + 'code.function': 'test_instrument_ag2_conversation_groupchat_and_turn_spans', + 'code.lineno': 123, + 'ag2.runner_name': 'user_proxy', + 'ag2.recipient_name': 'chat_manager', + 'ag2.user_message': 'What is AG2?', + 'logfire.msg_template': 'AG2 conversation', + 'logfire.msg': 'AG2 conversation', + 'logfire.span_type': 'span', + 'ag2.total_messages': 2, + 'ag2.total_rounds': 2, + 'ag2.last_role': 'assistant', + 'ag2.termination_reason': 'TERMINATE', + 'logfire.json_schema': { + 'type': 'object', + 'properties': { + 'ag2.runner_name': {}, + 'ag2.recipient_name': {}, + 'ag2.user_message': {}, + 'ag2.total_messages': {}, + 'ag2.total_rounds': {}, + 'ag2.last_role': {}, + 'ag2.termination_reason': {}, + }, + }, + }, + }, + ] + ) + + +def test_instrument_ag2_tool_spans(exporter: TestExporter) -> None: + if autogen is None: # pragma: no cover + return + + proxy = _new_user_proxy('tool_proxy') + + @proxy.register_for_execution() + def search_knowledge(query: str) -> str: + return f'Results for {query}' + + _ = search_knowledge + + with logfire.instrument_ag2(record_content=True): + success, payload = proxy.execute_function( + {'name': 'search_knowledge', 'arguments': '{"query": "AG2"}'}, call_id='call-1' + ) + + assert success is True + assert payload['content'] == 'Results for AG2' + + assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( + [ + { + 'name': 'AG2 tool execution', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 2000000000, + 'attributes': { + 'code.filepath': 'test_ag2.py', + 'code.function': 'test_instrument_ag2_tool_spans', + 'code.lineno': 123, + 'ag2.agent_name': 'tool_proxy', + 'ag2.tool_name': 'search_knowledge', + 'ag2.call_id': 'call-1', + 'ag2.tool_arg_names': ['query'], + 'ag2.tool_args': {'query': 'AG2'}, + 'logfire.msg_template': 'AG2 tool execution', + 'logfire.msg': 'AG2 tool execution', + 'logfire.span_type': 'span', + 'ag2.execution.success': True, + 'ag2.tool_result': 'Results for AG2', + 'logfire.json_schema': { + 'type': 'object', + 'properties': { + 'ag2.agent_name': {}, + 'ag2.tool_name': {}, + 'ag2.call_id': {}, + 'ag2.tool_arg_names': {'type': 'array'}, + 'ag2.tool_args': {'type': 'object'}, + 'ag2.execution.success': {}, + 'ag2.tool_result': {}, + }, + }, + }, + } + ] + ) + + +def test_instrument_ag2_record_content_false_omits_message_content( + exporter: TestExporter, + monkeypatch: Any, +) -> None: + if autogen is None: # pragma: no cover + return + + assistant = _new_user_proxy('assistant_agent') + + def fake_generate_reply( + self: Any, messages: list[dict[str, Any]] | None = None, sender: Any = None, exclude: Any = () + ) -> dict[str, Any]: + return {'role': 'assistant', 'content': 'ok'} + + monkeypatch.setattr(autogen.ConversableAgent, 'generate_reply', fake_generate_reply) + + with logfire.instrument_ag2(record_content=False): + assistant.generate_reply(messages=[{'role': 'user', 'content': 'secret prompt'}], sender=None) + + assert exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot( + [ + { + 'name': 'AG2 agent turn', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 2000000000, + 'attributes': { + 'code.filepath': 'test_ag2.py', + 'code.function': 'test_instrument_ag2_record_content_false_omits_message_content', + 'code.lineno': 123, + 'ag2.agent_name': 'assistant_agent', + 'ag2.message_role': 'user', + 'logfire.msg_template': 'AG2 agent turn', + 'logfire.msg': 'AG2 agent turn', + 'logfire.json_schema': { + 'type': 'object', + 'properties': {'ag2.agent_name': {}, 'ag2.message_role': {}}, + }, + 'logfire.span_type': 'span', + }, + } + ] + ) + + +def test_instrument_ag2_unpatches_on_context_exit() -> None: + if autogen is None: # pragma: no cover + return + + original = autogen.ConversableAgent.generate_reply + + with logfire.instrument_ag2(): + assert autogen.ConversableAgent.generate_reply is not original + + assert autogen.ConversableAgent.generate_reply is original + + +def test_instrument_ag2_eager_patching() -> None: + """Calling instrument_ag2() without `with` should still apply patches immediately.""" + if autogen is None: # pragma: no cover + return + + original = autogen.ConversableAgent.generate_reply + + ctx = logfire.instrument_ag2() + # Patches applied eagerly, before entering the context manager + assert autogen.ConversableAgent.generate_reply is not original + + # Clean up by entering and exiting the context manager + with ctx: + pass + + assert autogen.ConversableAgent.generate_reply is original diff --git a/tests/test_logfire_api.py b/tests/test_logfire_api.py index 85ada5197..9e7ab83c4 100644 --- a/tests/test_logfire_api.py +++ b/tests/test_logfire_api.py @@ -207,6 +207,20 @@ def func() -> None: ... logfire_api.instrument_pydantic_ai() logfire__all__.remove('instrument_pydantic_ai') + assert hasattr(logfire_api, 'instrument_ag2') + try: + with warnings.catch_warnings(): + warnings.filterwarnings( + 'ignore', message='jsonschema.RefResolver is deprecated.*', category=DeprecationWarning + ) + importlib.import_module('autogen') + except ImportError: + pass + else: + with logfire_api.instrument_ag2(): + ... + logfire__all__.remove('instrument_ag2') + assert hasattr(logfire_api, 'instrument_mcp') if sys.version_info >= (3, 10) and get_version(pydantic_version) >= get_version('2.11.0'): logfire_api.instrument_mcp() diff --git a/uv.lock b/uv.lock index 4376df0f5..717fc1195 100644 --- a/uv.lock +++ b/uv.lock @@ -20,7 +20,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-02T13:43:56.677963Z" +exclude-newer = "2026-04-02T22:29:32.955013Z" exclude-newer-span = "P1W" [options.exclude-newer-package] @@ -37,6 +37,32 @@ members = [ "logfire-api", ] +[[package]] +name = "ag2" +version = "0.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "diskcache", marker = "python_full_version >= '3.10'" }, + { name = "docker", marker = "python_full_version >= '3.10'" }, + { name = "fast-depends", extra = ["pydantic"], marker = "python_full_version >= '3.10'" }, + { name = "httpx", marker = "python_full_version >= '3.10'" }, + { name = "packaging", version = "26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic", marker = "python_full_version >= '3.10'" }, + { name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "termcolor", marker = "python_full_version >= '3.10'" }, + { name = "tiktoken", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/20/46159b56f9c6069ea2bc174f3ce3597febc4f4bbf467c2c1e003c84f0da7/ag2-0.11.4.tar.gz", hash = "sha256:1949d27e889a908a258ad4bcda4bfb33770763a2e8f9ea2da13eec342da8f493", size = 3818369, upload-time = "2026-03-17T23:47:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/14/0d226e5fbb02949ae1ded5bcaa695a1ca11bb9535c71b53dfbc6ff2c4b5e/ag2-0.11.4-py3-none-any.whl", hash = "sha256:182805d940271bd9cfea2a40f5dc1f3db351745b07135efdb015a7f0c0681fd4", size = 1080405, upload-time = "2026-03-17T23:47:48.15Z" }, +] + +[package.optional-dependencies] +openai = [ + { name = "openai", marker = "python_full_version >= '3.10'" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -1638,6 +1664,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fast-depends" +version = "3.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/6d/787a21ca8043a8fdb737cf28f645e94a46fc30b44a31de54573299156bad/fast_depends-3.0.8.tar.gz", hash = "sha256:896b16f79a512b6ea1df721b0aa1708a192a06f964be6597e01fcf5412559101", size = 18382, upload-time = "2026-03-02T19:54:28.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/1d/e4843e4eeb65f51447b8c22d200d12d8f94f27c97e77bb7162515cc8d61f/fast_depends-3.0.8-py3-none-any.whl", hash = "sha256:4c52c8a3907bca46d43e70e4364d6d016872d9a3aae4bc0c1c85e72e0a6a21c7", size = 25507, upload-time = "2026-03-02T19:54:27.594Z" }, +] + +[package.optional-dependencies] +pydantic = [ + { name = "pydantic", marker = "python_full_version >= '3.10'" }, +] + [[package]] name = "fastapi" version = "0.128.8" @@ -3439,6 +3483,7 @@ wsgi = [ [package.dev-dependencies] dev = [ + { name = "ag2", extra = ["openai"], marker = "python_full_version >= '3.10'" }, { name = "aiohttp" }, { name = "aiosqlite" }, { name = "anthropic" }, @@ -3618,6 +3663,7 @@ provides-extras = ["system-metrics", "asgi", "wsgi", "aiohttp", "aiohttp-client" [package.metadata.requires-dev] dev = [ + { name = "ag2", extras = ["openai"], marker = "python_full_version >= '3.10'", specifier = ">=0.11.4,<1.0" }, { name = "aiohttp", specifier = ">=3.10.9" }, { name = "aiosqlite", specifier = "!=0.22.*" }, { name = "anthropic", specifier = ">=0.27.0" }, @@ -8754,6 +8800,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + [[package]] name = "testcontainers" version = "4.12.0"