From 1127296fc467b817e330eec24f08458adea33231 Mon Sep 17 00:00:00 2001 From: Amit Mukherjee Date: Thu, 26 Feb 2026 11:11:20 -0600 Subject: [PATCH 1/6] Python: Add OpenTelemetry instrumentation to ClaudeAgent (#4278) Add inline telemetry to ClaudeAgent.run() so that enable_instrumentation() emits invoke_agent spans and metrics. Covers both streaming and non-streaming paths using the same observability helpers as AgentTelemetryLayer. Adds 5 unit tests for telemetry behavior. Co-Authored-By: amitmukh --- .../claude/agent_framework_claude/_agent.py | 117 +++++++++- .../claude/tests/test_claude_agent.py | 208 ++++++++++++++++++ 2 files changed, 324 insertions(+), 1 deletion(-) diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 43f001b3db..44be403f63 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -5,8 +5,10 @@ import contextlib import logging import sys +import weakref from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence from pathlib import Path +from time import perf_counter, time_ns from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, overload from agent_framework import ( @@ -27,6 +29,19 @@ normalize_tools, ) from agent_framework.exceptions import AgentException +from agent_framework.observability import ( + OBSERVABILITY_SETTINGS, + OtelAttr, + get_tracer, +) +from agent_framework.observability import ( + _capture_messages as capture_messages, + _capture_response as capture_response, + _get_response_attributes as get_response_attributes, + _get_span as get_span, + _get_span_attributes as get_span_attributes, + capture_exception, +) from claude_agent_sdk import ( AssistantMessage, ClaudeSDKClient, @@ -620,9 +635,109 @@ def run( self._get_stream(messages, session=session, options=options, **kwargs), finalizer=self._finalize_response, ) + + if not OBSERVABILITY_SETTINGS.ENABLED: + if stream: + return response + return response.get_final_response() + + provider_name = self.AGENT_PROVIDER_NAME + attributes = get_span_attributes( + operation_name=OtelAttr.AGENT_INVOKE_OPERATION, + provider_name=provider_name, + agent_id=self.id, + agent_name=self.name or self.id, + agent_description=self.description, + thread_id=session.service_session_id if session else None, + ) + if stream: + return self._run_with_telemetry_stream( + response, attributes, provider_name, messages, + ) + + return self._run_with_telemetry( + response, attributes, provider_name, messages, + ) + + def _run_with_telemetry_stream( + self, + result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]], + attributes: dict[str, Any], + provider_name: str, + messages: AgentRunInputs | None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Wrap a streaming run with OpenTelemetry tracing.""" + operation = attributes.get(OtelAttr.OPERATION, "operation") + span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown") + span = get_tracer().start_span(f"{operation} {span_name}") + span.set_attributes(attributes) + + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + capture_messages(span=span, provider_name=provider_name, messages=messages) + + span_state = {"closed": False} + duration_state: dict[str, float] = {} + start_time = perf_counter() + + def _close_span() -> None: + if span_state["closed"]: + return + span_state["closed"] = True + span.end() + + def _record_duration() -> None: + duration_state["duration"] = perf_counter() - start_time + + async def _finalize_stream() -> None: + try: + response = await result_stream.get_final_response() + duration = duration_state.get("duration") + response_attributes = get_response_attributes(attributes, response) + capture_response(span=span, attributes=response_attributes, duration=duration) + if ( + OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED + and isinstance(response, AgentResponse) + and response.messages + ): + capture_messages( + span=span, provider_name=provider_name, messages=response.messages, output=True, + ) + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + finally: + _close_span() + + wrapped_stream = result_stream.with_cleanup_hook(_record_duration).with_cleanup_hook(_finalize_stream) + weakref.finalize(wrapped_stream, _close_span) + return wrapped_stream + + async def _run_with_telemetry( + self, + result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]], + attributes: dict[str, Any], + provider_name: str, + messages: AgentRunInputs | None, + ) -> AgentResponse[Any]: + """Wrap a non-streaming run with OpenTelemetry tracing.""" + with get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + capture_messages(span=span, provider_name=provider_name, messages=messages) + start_time = perf_counter() + try: + response = await result_stream.get_final_response() + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + raise + duration = perf_counter() - start_time + if response: + response_attributes = get_response_attributes(attributes, response) + capture_response(span=span, attributes=response_attributes, duration=duration) + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + capture_messages( + span=span, provider_name=provider_name, messages=response.messages, output=True, + ) return response - return response.get_final_response() def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: """Build AgentResponse and propagate structured_output as value. diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py index 0e126c36b9..2f00ea8233 100644 --- a/python/packages/claude/tests/test_claude_agent.py +++ b/python/packages/claude/tests/test_claude_agent.py @@ -945,3 +945,211 @@ async def test_structured_output_with_error_does_not_propagate(self) -> None: with pytest.raises(AgentException) as exc_info: await agent.run("Hello") assert "Something went wrong" in str(exc_info.value) + + +# region Test ClaudeAgent Telemetry + + +class TestClaudeAgentTelemetry: + """Tests for ClaudeAgent OpenTelemetry instrumentation.""" + + @staticmethod + async def _create_async_generator(items: list[Any]) -> Any: + """Helper to create async generator from list.""" + for item in items: + yield item + + def _create_mock_client(self, messages: list[Any]) -> MagicMock: + """Create a mock ClaudeSDKClient that yields given messages.""" + mock_client = MagicMock() + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.query = AsyncMock() + mock_client.set_model = AsyncMock() + mock_client.set_permission_mode = AsyncMock() + mock_client.receive_response = MagicMock(return_value=self._create_async_generator(messages)) + return mock_client + + def _create_standard_messages(self) -> list[Any]: + """Create a standard set of mock messages for testing.""" + from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock + from claude_agent_sdk.types import StreamEvent + + return [ + StreamEvent( + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "Hello!"}, + }, + uuid="event-1", + session_id="session-123", + ), + AssistantMessage( + content=[TextBlock(text="Hello!")], + model="claude-sonnet", + ), + ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=50, + is_error=False, + num_turns=1, + session_id="session-123", + ), + ] + + async def test_run_emits_span_when_instrumentation_enabled(self) -> None: + """Test that run() creates an OpenTelemetry span when instrumentation is enabled.""" + from agent_framework.observability import OBSERVABILITY_SETTINGS + + messages = self._create_standard_messages() + mock_client = self._create_mock_client(messages) + + original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation + try: + OBSERVABILITY_SETTINGS.enable_instrumentation = True + + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_span") as mock_get_span, + ): + mock_span = MagicMock() + mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) + mock_get_span.return_value.__exit__ = MagicMock(return_value=False) + + agent = ClaudeAgent(name="test-agent") + response = await agent.run("Hello") + + assert response.text == "Hello!" + mock_get_span.assert_called_once() + call_kwargs = mock_get_span.call_args[1] + assert call_kwargs["attributes"]["gen_ai.agent.name"] == "test-agent" + assert call_kwargs["attributes"]["gen_ai.operation.name"] == "invoke_agent" + finally: + OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled + + async def test_run_skips_telemetry_when_instrumentation_disabled(self) -> None: + """Test that run() skips telemetry when instrumentation is disabled.""" + from agent_framework.observability import OBSERVABILITY_SETTINGS + + messages = self._create_standard_messages() + mock_client = self._create_mock_client(messages) + + original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation + try: + OBSERVABILITY_SETTINGS.enable_instrumentation = False + + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_span") as mock_get_span, + ): + agent = ClaudeAgent(name="test-agent") + response = await agent.run("Hello") + + assert response.text == "Hello!" + mock_get_span.assert_not_called() + finally: + OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled + + async def test_run_stream_emits_span_when_instrumentation_enabled(self) -> None: + """Test that run(stream=True) creates a span when instrumentation is enabled.""" + from agent_framework.observability import OBSERVABILITY_SETTINGS + + messages = self._create_standard_messages() + mock_client = self._create_mock_client(messages) + + original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation + try: + OBSERVABILITY_SETTINGS.enable_instrumentation = True + + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_tracer") as mock_get_tracer, + ): + mock_span = MagicMock() + mock_tracer = MagicMock() + mock_tracer.start_span.return_value = mock_span + mock_get_tracer.return_value = mock_tracer + + agent = ClaudeAgent(name="stream-agent") + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + mock_tracer.start_span.assert_called_once() + span_name = mock_tracer.start_span.call_args[0][0] + assert "stream-agent" in span_name + assert "invoke_agent" in span_name + finally: + OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled + + async def test_run_captures_exception_in_span(self) -> None: + """Test that exceptions during run() are captured in the telemetry span.""" + from agent_framework.exceptions import AgentException + from agent_framework.observability import OBSERVABILITY_SETTINGS + from claude_agent_sdk import ResultMessage + + error_messages = [ + ResultMessage( + subtype="error", + duration_ms=100, + duration_api_ms=50, + is_error=True, + num_turns=0, + session_id="error-session", + result="Model not found", + ), + ] + mock_client = self._create_mock_client(error_messages) + + original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation + try: + OBSERVABILITY_SETTINGS.enable_instrumentation = True + + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_span") as mock_get_span, + patch("agent_framework_claude._agent.capture_exception") as mock_capture_exc, + ): + mock_span = MagicMock() + mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) + mock_get_span.return_value.__exit__ = MagicMock(return_value=False) + + agent = ClaudeAgent(name="error-agent") + with pytest.raises(AgentException): + await agent.run("Hello") + + mock_capture_exc.assert_called_once() + exc_kwargs = mock_capture_exc.call_args[1] + assert exc_kwargs["span"] is mock_span + assert isinstance(exc_kwargs["exception"], AgentException) + finally: + OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled + + async def test_telemetry_uses_correct_provider_name(self) -> None: + """Test that telemetry uses AGENT_PROVIDER_NAME as provider.""" + from agent_framework.observability import OBSERVABILITY_SETTINGS + + messages = self._create_standard_messages() + mock_client = self._create_mock_client(messages) + + original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation + try: + OBSERVABILITY_SETTINGS.enable_instrumentation = True + + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_span") as mock_get_span, + ): + mock_span = MagicMock() + mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) + mock_get_span.return_value.__exit__ = MagicMock(return_value=False) + + agent = ClaudeAgent(name="test-agent") + await agent.run("Hello") + + call_kwargs = mock_get_span.call_args[1] + assert call_kwargs["attributes"]["gen_ai.provider.name"] == "anthropic.claude" + finally: + OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled From 6e60f49ebfa29de75d7d55143a02193804d15031 Mon Sep 17 00:00:00 2001 From: Amit Mukherjee Date: Thu, 26 Feb 2026 11:32:37 -0600 Subject: [PATCH 2/6] Address PR review feedback for ClaudeAgent telemetry - Add justification comment for private observability API imports - Pass system_instructions to capture_messages for system prompt capture - Use monkeypatch instead of try/finally for test global state isolation Co-Authored-By: amitmukh Co-Authored-By: Claude --- .../claude/agent_framework_claude/_agent.py | 18 +- .../claude/tests/test_claude_agent.py | 194 ++++++++---------- 2 files changed, 103 insertions(+), 109 deletions(-) diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 44be403f63..59a18e6af2 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -34,6 +34,10 @@ OtelAttr, get_tracer, ) +# These internal helpers are used by AgentTelemetryLayer to build spans. +# ClaudeAgent cannot inherit AgentTelemetryLayer (MRO conflict with its own +# run()), so we reuse the same helpers directly. If the core package later +# exposes public equivalents, this import block should be updated. from agent_framework.observability import ( _capture_messages as capture_messages, _capture_response as capture_response, @@ -674,7 +678,12 @@ def _run_with_telemetry_stream( span.set_attributes(attributes) if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: - capture_messages(span=span, provider_name=provider_name, messages=messages) + capture_messages( + span=span, + provider_name=provider_name, + messages=messages, + system_instructions=self._default_options.get("system_prompt"), + ) span_state = {"closed": False} duration_state: dict[str, float] = {} @@ -722,7 +731,12 @@ async def _run_with_telemetry( """Wrap a non-streaming run with OpenTelemetry tracing.""" with get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: - capture_messages(span=span, provider_name=provider_name, messages=messages) + capture_messages( + span=span, + provider_name=provider_name, + messages=messages, + system_instructions=self._default_options.get("system_prompt"), + ) start_time = perf_counter() try: response = await result_stream.get_final_response() diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py index 2f00ea8233..99ebd58e58 100644 --- a/python/packages/claude/tests/test_claude_agent.py +++ b/python/packages/claude/tests/test_claude_agent.py @@ -998,93 +998,81 @@ def _create_standard_messages(self) -> list[Any]: ), ] - async def test_run_emits_span_when_instrumentation_enabled(self) -> None: + async def test_run_emits_span_when_instrumentation_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that run() creates an OpenTelemetry span when instrumentation is enabled.""" from agent_framework.observability import OBSERVABILITY_SETTINGS messages = self._create_standard_messages() mock_client = self._create_mock_client(messages) - original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation - try: - OBSERVABILITY_SETTINGS.enable_instrumentation = True - - with ( - patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_span") as mock_get_span, - ): - mock_span = MagicMock() - mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) - mock_get_span.return_value.__exit__ = MagicMock(return_value=False) - - agent = ClaudeAgent(name="test-agent") - response = await agent.run("Hello") - - assert response.text == "Hello!" - mock_get_span.assert_called_once() - call_kwargs = mock_get_span.call_args[1] - assert call_kwargs["attributes"]["gen_ai.agent.name"] == "test-agent" - assert call_kwargs["attributes"]["gen_ai.operation.name"] == "invoke_agent" - finally: - OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled - - async def test_run_skips_telemetry_when_instrumentation_disabled(self) -> None: + monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", True) + + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_span") as mock_get_span, + ): + mock_span = MagicMock() + mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) + mock_get_span.return_value.__exit__ = MagicMock(return_value=False) + + agent = ClaudeAgent(name="test-agent") + response = await agent.run("Hello") + + assert response.text == "Hello!" + mock_get_span.assert_called_once() + call_kwargs = mock_get_span.call_args[1] + assert call_kwargs["attributes"]["gen_ai.agent.name"] == "test-agent" + assert call_kwargs["attributes"]["gen_ai.operation.name"] == "invoke_agent" + + async def test_run_skips_telemetry_when_instrumentation_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that run() skips telemetry when instrumentation is disabled.""" from agent_framework.observability import OBSERVABILITY_SETTINGS messages = self._create_standard_messages() mock_client = self._create_mock_client(messages) - original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation - try: - OBSERVABILITY_SETTINGS.enable_instrumentation = False + monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", False) - with ( - patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_span") as mock_get_span, - ): - agent = ClaudeAgent(name="test-agent") - response = await agent.run("Hello") + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_span") as mock_get_span, + ): + agent = ClaudeAgent(name="test-agent") + response = await agent.run("Hello") - assert response.text == "Hello!" - mock_get_span.assert_not_called() - finally: - OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled + assert response.text == "Hello!" + mock_get_span.assert_not_called() - async def test_run_stream_emits_span_when_instrumentation_enabled(self) -> None: + async def test_run_stream_emits_span_when_instrumentation_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that run(stream=True) creates a span when instrumentation is enabled.""" from agent_framework.observability import OBSERVABILITY_SETTINGS messages = self._create_standard_messages() mock_client = self._create_mock_client(messages) - original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation - try: - OBSERVABILITY_SETTINGS.enable_instrumentation = True - - with ( - patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_tracer") as mock_get_tracer, - ): - mock_span = MagicMock() - mock_tracer = MagicMock() - mock_tracer.start_span.return_value = mock_span - mock_get_tracer.return_value = mock_tracer - - agent = ClaudeAgent(name="stream-agent") - updates: list[AgentResponseUpdate] = [] - async for update in agent.run("Hello", stream=True): - updates.append(update) - - assert len(updates) == 1 - mock_tracer.start_span.assert_called_once() - span_name = mock_tracer.start_span.call_args[0][0] - assert "stream-agent" in span_name - assert "invoke_agent" in span_name - finally: - OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled - - async def test_run_captures_exception_in_span(self) -> None: + monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", True) + + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_tracer") as mock_get_tracer, + ): + mock_span = MagicMock() + mock_tracer = MagicMock() + mock_tracer.start_span.return_value = mock_span + mock_get_tracer.return_value = mock_tracer + + agent = ClaudeAgent(name="stream-agent") + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + mock_tracer.start_span.assert_called_once() + span_name = mock_tracer.start_span.call_args[0][0] + assert "stream-agent" in span_name + assert "invoke_agent" in span_name + + async def test_run_captures_exception_in_span(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that exceptions during run() are captured in the telemetry span.""" from agent_framework.exceptions import AgentException from agent_framework.observability import OBSERVABILITY_SETTINGS @@ -1103,53 +1091,45 @@ async def test_run_captures_exception_in_span(self) -> None: ] mock_client = self._create_mock_client(error_messages) - original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation - try: - OBSERVABILITY_SETTINGS.enable_instrumentation = True - - with ( - patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_span") as mock_get_span, - patch("agent_framework_claude._agent.capture_exception") as mock_capture_exc, - ): - mock_span = MagicMock() - mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) - mock_get_span.return_value.__exit__ = MagicMock(return_value=False) - - agent = ClaudeAgent(name="error-agent") - with pytest.raises(AgentException): - await agent.run("Hello") - - mock_capture_exc.assert_called_once() - exc_kwargs = mock_capture_exc.call_args[1] - assert exc_kwargs["span"] is mock_span - assert isinstance(exc_kwargs["exception"], AgentException) - finally: - OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled - - async def test_telemetry_uses_correct_provider_name(self) -> None: + monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", True) + + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_span") as mock_get_span, + patch("agent_framework_claude._agent.capture_exception") as mock_capture_exc, + ): + mock_span = MagicMock() + mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) + mock_get_span.return_value.__exit__ = MagicMock(return_value=False) + + agent = ClaudeAgent(name="error-agent") + with pytest.raises(AgentException): + await agent.run("Hello") + + mock_capture_exc.assert_called_once() + exc_kwargs = mock_capture_exc.call_args[1] + assert exc_kwargs["span"] is mock_span + assert isinstance(exc_kwargs["exception"], AgentException) + + async def test_telemetry_uses_correct_provider_name(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that telemetry uses AGENT_PROVIDER_NAME as provider.""" from agent_framework.observability import OBSERVABILITY_SETTINGS messages = self._create_standard_messages() mock_client = self._create_mock_client(messages) - original_enabled = OBSERVABILITY_SETTINGS.enable_instrumentation - try: - OBSERVABILITY_SETTINGS.enable_instrumentation = True + monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", True) - with ( - patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_span") as mock_get_span, - ): - mock_span = MagicMock() - mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) - mock_get_span.return_value.__exit__ = MagicMock(return_value=False) + with ( + patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), + patch("agent_framework_claude._agent.get_span") as mock_get_span, + ): + mock_span = MagicMock() + mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) + mock_get_span.return_value.__exit__ = MagicMock(return_value=False) - agent = ClaudeAgent(name="test-agent") - await agent.run("Hello") + agent = ClaudeAgent(name="test-agent") + await agent.run("Hello") - call_kwargs = mock_get_span.call_args[1] - assert call_kwargs["attributes"]["gen_ai.provider.name"] == "anthropic.claude" - finally: - OBSERVABILITY_SETTINGS.enable_instrumentation = original_enabled + call_kwargs = mock_get_span.call_args[1] + assert call_kwargs["attributes"]["gen_ai.provider.name"] == "anthropic.claude" From b982ea919838eca0f9c4bdd910c279e27b1780f0 Mon Sep 17 00:00:00 2001 From: Amit Mukherjee Date: Fri, 27 Feb 2026 10:50:46 -0600 Subject: [PATCH 3/6] Adopt AgentTelemetryLayer instead of inline telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure ClaudeAgent to inherit from AgentTelemetryLayer via a _ClaudeAgentRunImpl mixin, eliminating duplicated telemetry code and private API imports. MRO: ClaudeAgent → AgentTelemetryLayer → _ClaudeAgentRunImpl → BaseAgent - Remove inline _run_with_telemetry / _run_with_telemetry_stream methods - Remove private observability helper imports (_capture_messages, etc.) - Add default_options property mapping system_prompt → instructions - Net -105 lines by reusing core telemetry layer Co-Authored-By: amitmukh Co-Authored-By: Claude --- .../claude/agent_framework_claude/_agent.py | 261 ++++++------------ .../claude/tests/test_claude_agent.py | 12 +- 2 files changed, 84 insertions(+), 189 deletions(-) diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 59a18e6af2..55fb116c91 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -5,10 +5,8 @@ import contextlib import logging import sys -import weakref from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence from pathlib import Path -from time import perf_counter, time_ns from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, overload from agent_framework import ( @@ -29,23 +27,7 @@ normalize_tools, ) from agent_framework.exceptions import AgentException -from agent_framework.observability import ( - OBSERVABILITY_SETTINGS, - OtelAttr, - get_tracer, -) -# These internal helpers are used by AgentTelemetryLayer to build spans. -# ClaudeAgent cannot inherit AgentTelemetryLayer (MRO conflict with its own -# run()), so we reuse the same helpers directly. If the core package later -# exposes public equivalents, this import block should be updated. -from agent_framework.observability import ( - _capture_messages as capture_messages, - _capture_response as capture_response, - _get_response_attributes as get_response_attributes, - _get_span as get_span, - _get_span_attributes as get_span_attributes, - capture_exception, -) +from agent_framework.observability import AgentTelemetryLayer from claude_agent_sdk import ( AssistantMessage, ClaudeSDKClient, @@ -190,7 +172,72 @@ class ClaudeAgentOptions(TypedDict, total=False): ) -class ClaudeAgent(BaseAgent, Generic[OptionsT]): +class _ClaudeAgentRunImpl: + """Core run() implementation for ClaudeAgent. + + Separated into a mixin so that AgentTelemetryLayer.run() can wrap it + via MRO: ClaudeAgent → AgentTelemetryLayer → _ClaudeAgentRunImpl → BaseAgent. + """ + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + options: Any = None, + **kwargs: Any, + ) -> AsyncIterable[AgentResponseUpdate]: ... + + @overload + async def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + options: Any = None, + **kwargs: Any, + ) -> AgentResponse[Any]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + options: Any = None, + **kwargs: Any, + ) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse[Any]]: + """Run the agent with the given messages. + + Args: + messages: The messages to process. + + Keyword Args: + stream: If True, returns an async iterable of updates. If False (default), + returns an awaitable AgentResponse. + session: The conversation session. If session has service_session_id set, + the agent will resume that session. + options: Runtime options (model, permission_mode can be changed per-request). + kwargs: Additional keyword arguments. + + Returns: + When stream=True: An ResponseStream for streaming updates. + When stream=False: An Awaitable[AgentResponse] with the complete response. + """ + response = ResponseStream( + self._get_stream(messages, session=session, options=options, **kwargs), # type: ignore[attr-defined] + finalizer=self._finalize_response, # type: ignore[attr-defined] + ) + + if stream: + return response + return response.get_final_response() + + +class ClaudeAgent(AgentTelemetryLayer, _ClaudeAgentRunImpl, BaseAgent, Generic[OptionsT]): """Claude Agent using Claude Code CLI. Wraps the Claude Agent SDK to provide agentic capabilities including @@ -587,171 +634,19 @@ def _format_prompt(self, messages: list[Message] | None) -> str: return "" return "\n".join([msg.text or "" for msg in messages]) - @overload - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: Literal[True], - session: AgentSession | None = None, - options: OptionsT | MutableMapping[str, Any] | None = None, - **kwargs: Any, - ) -> AsyncIterable[AgentResponseUpdate]: ... - - @overload - async def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: Literal[False] = ..., - session: AgentSession | None = None, - options: OptionsT | MutableMapping[str, Any] | None = None, - **kwargs: Any, - ) -> AgentResponse[Any]: ... - - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: bool = False, - session: AgentSession | None = None, - options: OptionsT | MutableMapping[str, Any] | None = None, - **kwargs: Any, - ) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse[Any]]: - """Run the agent with the given messages. - - Args: - messages: The messages to process. + @property + def default_options(self) -> dict[str, Any]: + """Expose options for AgentTelemetryLayer compatibility. - Keyword Args: - stream: If True, returns an async iterable of updates. If False (default), - returns an awaitable AgentResponse. - session: The conversation session. If session has service_session_id set, - the agent will resume that session. - options: Runtime options (model, permission_mode can be changed per-request). - kwargs: Additional keyword arguments. - - Returns: - When stream=True: An ResponseStream for streaming updates. - When stream=False: An Awaitable[AgentResponse] with the complete response. + AgentTelemetryLayer expects an ``instructions`` key to capture the + system prompt in telemetry spans. ClaudeAgent stores the system + prompt as ``system_prompt``, so this property maps accordingly. """ - response = ResponseStream( - self._get_stream(messages, session=session, options=options, **kwargs), - finalizer=self._finalize_response, - ) - - if not OBSERVABILITY_SETTINGS.ENABLED: - if stream: - return response - return response.get_final_response() - - provider_name = self.AGENT_PROVIDER_NAME - attributes = get_span_attributes( - operation_name=OtelAttr.AGENT_INVOKE_OPERATION, - provider_name=provider_name, - agent_id=self.id, - agent_name=self.name or self.id, - agent_description=self.description, - thread_id=session.service_session_id if session else None, - ) - - if stream: - return self._run_with_telemetry_stream( - response, attributes, provider_name, messages, - ) - - return self._run_with_telemetry( - response, attributes, provider_name, messages, - ) - - def _run_with_telemetry_stream( - self, - result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]], - attributes: dict[str, Any], - provider_name: str, - messages: AgentRunInputs | None, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: - """Wrap a streaming run with OpenTelemetry tracing.""" - operation = attributes.get(OtelAttr.OPERATION, "operation") - span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown") - span = get_tracer().start_span(f"{operation} {span_name}") - span.set_attributes(attributes) - - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: - capture_messages( - span=span, - provider_name=provider_name, - messages=messages, - system_instructions=self._default_options.get("system_prompt"), - ) - - span_state = {"closed": False} - duration_state: dict[str, float] = {} - start_time = perf_counter() - - def _close_span() -> None: - if span_state["closed"]: - return - span_state["closed"] = True - span.end() - - def _record_duration() -> None: - duration_state["duration"] = perf_counter() - start_time - - async def _finalize_stream() -> None: - try: - response = await result_stream.get_final_response() - duration = duration_state.get("duration") - response_attributes = get_response_attributes(attributes, response) - capture_response(span=span, attributes=response_attributes, duration=duration) - if ( - OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED - and isinstance(response, AgentResponse) - and response.messages - ): - capture_messages( - span=span, provider_name=provider_name, messages=response.messages, output=True, - ) - except Exception as exception: - capture_exception(span=span, exception=exception, timestamp=time_ns()) - finally: - _close_span() - - wrapped_stream = result_stream.with_cleanup_hook(_record_duration).with_cleanup_hook(_finalize_stream) - weakref.finalize(wrapped_stream, _close_span) - return wrapped_stream - - async def _run_with_telemetry( - self, - result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]], - attributes: dict[str, Any], - provider_name: str, - messages: AgentRunInputs | None, - ) -> AgentResponse[Any]: - """Wrap a non-streaming run with OpenTelemetry tracing.""" - with get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: - capture_messages( - span=span, - provider_name=provider_name, - messages=messages, - system_instructions=self._default_options.get("system_prompt"), - ) - start_time = perf_counter() - try: - response = await result_stream.get_final_response() - except Exception as exception: - capture_exception(span=span, exception=exception, timestamp=time_ns()) - raise - duration = perf_counter() - start_time - if response: - response_attributes = get_response_attributes(attributes, response) - capture_response(span=span, attributes=response_attributes, duration=duration) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: - capture_messages( - span=span, provider_name=provider_name, messages=response.messages, output=True, - ) - return response + opts = dict(self._default_options) + system_prompt = opts.pop("system_prompt", None) + if system_prompt is not None: + opts["instructions"] = system_prompt + return opts def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: """Build AgentResponse and propagate structured_output as value. diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py index 99ebd58e58..e48a3b05d9 100644 --- a/python/packages/claude/tests/test_claude_agent.py +++ b/python/packages/claude/tests/test_claude_agent.py @@ -1009,7 +1009,7 @@ async def test_run_emits_span_when_instrumentation_enabled(self, monkeypatch: py with ( patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_span") as mock_get_span, + patch("agent_framework.observability._get_span") as mock_get_span, ): mock_span = MagicMock() mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) @@ -1035,7 +1035,7 @@ async def test_run_skips_telemetry_when_instrumentation_disabled(self, monkeypat with ( patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_span") as mock_get_span, + patch("agent_framework.observability._get_span") as mock_get_span, ): agent = ClaudeAgent(name="test-agent") response = await agent.run("Hello") @@ -1054,7 +1054,7 @@ async def test_run_stream_emits_span_when_instrumentation_enabled(self, monkeypa with ( patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_tracer") as mock_get_tracer, + patch("agent_framework.observability.get_tracer") as mock_get_tracer, ): mock_span = MagicMock() mock_tracer = MagicMock() @@ -1095,8 +1095,8 @@ async def test_run_captures_exception_in_span(self, monkeypatch: pytest.MonkeyPa with ( patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_span") as mock_get_span, - patch("agent_framework_claude._agent.capture_exception") as mock_capture_exc, + patch("agent_framework.observability._get_span") as mock_get_span, + patch("agent_framework.observability.capture_exception") as mock_capture_exc, ): mock_span = MagicMock() mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) @@ -1122,7 +1122,7 @@ async def test_telemetry_uses_correct_provider_name(self, monkeypatch: pytest.Mo with ( patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client), - patch("agent_framework_claude._agent.get_span") as mock_get_span, + patch("agent_framework.observability._get_span") as mock_get_span, ): mock_span = MagicMock() mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span) From aba06911f6b02dc46a8fc448925978db9cd91712 Mon Sep 17 00:00:00 2001 From: Amit Mukherjee Date: Fri, 27 Feb 2026 13:57:18 -0600 Subject: [PATCH 4/6] Fix mypy: align _ClaudeAgentRunImpl.run() signature with AgentTelemetryLayer.run() Remove explicit `options` parameter from mixin's run() signature and extract it from **kwargs to match AgentTelemetryLayer's signature. Also align overload return types (ResponseStream, Awaitable) to match. Co-Authored-By: Claude --- .../claude/agent_framework_claude/_agent.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 55fb116c91..d6efc2e915 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -184,22 +184,20 @@ def run( self, messages: AgentRunInputs | None = None, *, - stream: Literal[True], + stream: Literal[False] = ..., session: AgentSession | None = None, - options: Any = None, **kwargs: Any, - ) -> AsyncIterable[AgentResponseUpdate]: ... + ) -> Awaitable[AgentResponse[Any]]: ... @overload - async def run( + def run( self, messages: AgentRunInputs | None = None, *, - stream: Literal[False] = ..., + stream: Literal[True], session: AgentSession | None = None, - options: Any = None, **kwargs: Any, - ) -> AgentResponse[Any]: ... + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( self, @@ -207,9 +205,8 @@ def run( *, stream: bool = False, session: AgentSession | None = None, - options: Any = None, **kwargs: Any, - ) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse[Any]]: + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent with the given messages. Args: @@ -220,13 +217,14 @@ def run( returns an awaitable AgentResponse. session: The conversation session. If session has service_session_id set, the agent will resume that session. - options: Runtime options (model, permission_mode can be changed per-request). - kwargs: Additional keyword arguments. + kwargs: Additional keyword arguments including 'options' for runtime options + (model, permission_mode can be changed per-request). Returns: When stream=True: An ResponseStream for streaming updates. When stream=False: An Awaitable[AgentResponse] with the complete response. """ + options = kwargs.pop("options", None) response = ResponseStream( self._get_stream(messages, session=session, options=options, **kwargs), # type: ignore[attr-defined] finalizer=self._finalize_response, # type: ignore[attr-defined] From 671aa88100fa9ed095d4f0126ff94af748f95c60 Mon Sep 17 00:00:00 2001 From: Amit Mukherjee Date: Mon, 2 Mar 2026 10:14:26 -0600 Subject: [PATCH 5/6] Introduce RawClaudeAgent following framework's RawAgent/Agent pattern Replace private _ClaudeAgentRunImpl mixin with public RawClaudeAgent class that contains all core logic (init, run, lifecycle, tools). ClaudeAgent becomes a thin wrapper that adds AgentTelemetryLayer. - RawClaudeAgent(BaseAgent): full implementation without telemetry - ClaudeAgent(AgentTelemetryLayer, RawClaudeAgent): adds OTel tracing - Export RawClaudeAgent from package __init__.py Users who want to skip telemetry or provide their own can use RawClaudeAgent directly. Co-Authored-By: Claude --- .../claude/agent_framework_claude/__init__.py | 3 +- .../claude/agent_framework_claude/_agent.py | 167 ++++++++++-------- 2 files changed, 94 insertions(+), 76 deletions(-) diff --git a/python/packages/claude/agent_framework_claude/__init__.py b/python/packages/claude/agent_framework_claude/__init__.py index 3c666f4a31..abf522fa4f 100644 --- a/python/packages/claude/agent_framework_claude/__init__.py +++ b/python/packages/claude/agent_framework_claude/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._agent import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings +from ._agent import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings, RawClaudeAgent try: __version__ = importlib.metadata.version(__name__) @@ -13,5 +13,6 @@ "ClaudeAgent", "ClaudeAgentOptions", "ClaudeAgentSettings", + "RawClaudeAgent", "__version__", ] diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index d6efc2e915..22796ab6d6 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -172,71 +172,11 @@ class ClaudeAgentOptions(TypedDict, total=False): ) -class _ClaudeAgentRunImpl: - """Core run() implementation for ClaudeAgent. +class RawClaudeAgent(BaseAgent, Generic[OptionsT]): + """Claude Agent using Claude Code CLI without telemetry layers. - Separated into a mixin so that AgentTelemetryLayer.run() can wrap it - via MRO: ClaudeAgent → AgentTelemetryLayer → _ClaudeAgentRunImpl → BaseAgent. - """ - - @overload - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: Literal[False] = ..., - session: AgentSession | None = None, - **kwargs: Any, - ) -> Awaitable[AgentResponse[Any]]: ... - - @overload - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: Literal[True], - session: AgentSession | None = None, - **kwargs: Any, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... - - def run( - self, - messages: AgentRunInputs | None = None, - *, - stream: bool = False, - session: AgentSession | None = None, - **kwargs: Any, - ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: - """Run the agent with the given messages. - - Args: - messages: The messages to process. - - Keyword Args: - stream: If True, returns an async iterable of updates. If False (default), - returns an awaitable AgentResponse. - session: The conversation session. If session has service_session_id set, - the agent will resume that session. - kwargs: Additional keyword arguments including 'options' for runtime options - (model, permission_mode can be changed per-request). - - Returns: - When stream=True: An ResponseStream for streaming updates. - When stream=False: An Awaitable[AgentResponse] with the complete response. - """ - options = kwargs.pop("options", None) - response = ResponseStream( - self._get_stream(messages, session=session, options=options, **kwargs), # type: ignore[attr-defined] - finalizer=self._finalize_response, # type: ignore[attr-defined] - ) - - if stream: - return response - return response.get_final_response() - - -class ClaudeAgent(AgentTelemetryLayer, _ClaudeAgentRunImpl, BaseAgent, Generic[OptionsT]): - """Claude Agent using Claude Code CLI. + This is the core Claude agent implementation without OpenTelemetry instrumentation. + For most use cases, prefer :class:`ClaudeAgent` which includes telemetry support. Wraps the Claude Agent SDK to provide agentic capabilities including tool use, session management, and streaming responses. @@ -252,9 +192,9 @@ class ClaudeAgent(AgentTelemetryLayer, _ClaudeAgentRunImpl, BaseAgent, Generic[O .. code-block:: python - from agent_framework_claude import ClaudeAgent + from agent_framework_claude import RawClaudeAgent - async with ClaudeAgent( + async with RawClaudeAgent( instructions="You are a helpful assistant.", ) as agent: response = await agent.run("Hello!") @@ -264,7 +204,7 @@ class ClaudeAgent(AgentTelemetryLayer, _ClaudeAgentRunImpl, BaseAgent, Generic[O .. code-block:: python - async with ClaudeAgent() as agent: + async with RawClaudeAgent() as agent: async for update in agent.run("Write a poem"): print(update.text, end="", flush=True) @@ -272,7 +212,7 @@ class ClaudeAgent(AgentTelemetryLayer, _ClaudeAgentRunImpl, BaseAgent, Generic[O .. code-block:: python - async with ClaudeAgent() as agent: + async with RawClaudeAgent() as agent: session = agent.create_session() await agent.run("Remember my name is Alice", session=session) response = await agent.run("What's my name?", session=session) @@ -289,7 +229,7 @@ def greet(name: str) -> str: \"\"\"Greet someone by name.\"\"\" return f"Hello, {name}!" - async with ClaudeAgent(tools=[greet]) as agent: + async with RawClaudeAgent(tools=[greet]) as agent: response = await agent.run("Greet Alice") """ @@ -310,7 +250,7 @@ def __init__( env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize a ClaudeAgent instance. + """Initialize a RawClaudeAgent instance. Args: instructions: System prompt for the agent. @@ -407,7 +347,7 @@ def _normalize_tools( normalized = normalize_tools(tool) self._custom_tools.extend(normalized) - async def __aenter__(self) -> ClaudeAgent[OptionsT]: + async def __aenter__(self) -> RawClaudeAgent[OptionsT]: """Start the agent when entering async context.""" await self.start() return self @@ -634,11 +574,11 @@ def _format_prompt(self, messages: list[Message] | None) -> str: @property def default_options(self) -> dict[str, Any]: - """Expose options for AgentTelemetryLayer compatibility. + """Expose options with ``instructions`` key. - AgentTelemetryLayer expects an ``instructions`` key to capture the - system prompt in telemetry spans. ClaudeAgent stores the system - prompt as ``system_prompt``, so this property maps accordingly. + Maps ``system_prompt`` to ``instructions`` for compatibility with + :class:`AgentTelemetryLayer`, which reads the system prompt from + the ``instructions`` key. """ opts = dict(self._default_options) system_prompt = opts.pop("system_prompt", None) @@ -658,6 +598,61 @@ def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentRes structured_output = getattr(self, "_structured_output", None) return AgentResponse.from_updates(updates, value=structured_output) + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Run the agent with the given messages. + + Args: + messages: The messages to process. + + Keyword Args: + stream: If True, returns an async iterable of updates. If False (default), + returns an awaitable AgentResponse. + session: The conversation session. If session has service_session_id set, + the agent will resume that session. + kwargs: Additional keyword arguments including 'options' for runtime options + (model, permission_mode can be changed per-request). + + Returns: + When stream=True: An ResponseStream for streaming updates. + When stream=False: An Awaitable[AgentResponse] with the complete response. + """ + options = kwargs.pop("options", None) + response = ResponseStream( + self._get_stream(messages, session=session, options=options, **kwargs), + finalizer=self._finalize_response, + ) + + if stream: + return response + return response.get_final_response() + async def _get_stream( self, messages: AgentRunInputs | None = None, @@ -743,3 +738,25 @@ async def _get_stream( # Store structured output for the finalizer self._structured_output = structured_output + + +class ClaudeAgent(AgentTelemetryLayer, RawClaudeAgent[OptionsT], Generic[OptionsT]): + """Claude Agent with OpenTelemetry instrumentation. + + This is the recommended agent class for most use cases. It includes + OpenTelemetry-based telemetry for observability. For a minimal + implementation without telemetry, use :class:`RawClaudeAgent`. + + Examples: + Basic usage with context manager: + + .. code-block:: python + + from agent_framework_claude import ClaudeAgent + + async with ClaudeAgent( + instructions="You are a helpful assistant.", + ) as agent: + response = await agent.run("Hello!") + print(response.text) + """ From 4efd5e3e2b83828ac5428dd74ff6d61a8142be2d Mon Sep 17 00:00:00 2001 From: Amit Mukherjee Date: Tue, 3 Mar 2026 13:08:03 -0600 Subject: [PATCH 6/6] Address review nits: trim RawClaudeAgent docstring, fix import paths - Simplify RawClaudeAgent docstring to a single basic example (not the primary entry point for most users) - Use agent_framework.anthropic import path in docstrings instead of direct agent_framework_claude path - Add RawClaudeAgent to agent_framework.anthropic lazy re-exports Co-Authored-By: Claude --- .../claude/agent_framework_claude/_agent.py | 36 ++----------------- .../agent_framework/anthropic/__init__.py | 2 ++ 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 22796ab6d6..f5aabc43a9 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -192,45 +192,13 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): .. code-block:: python - from agent_framework_claude import RawClaudeAgent + from agent_framework.anthropic import RawClaudeAgent async with RawClaudeAgent( instructions="You are a helpful assistant.", ) as agent: response = await agent.run("Hello!") print(response.text) - - With streaming: - - .. code-block:: python - - async with RawClaudeAgent() as agent: - async for update in agent.run("Write a poem"): - print(update.text, end="", flush=True) - - With session management: - - .. code-block:: python - - async with RawClaudeAgent() as agent: - session = agent.create_session() - await agent.run("Remember my name is Alice", session=session) - response = await agent.run("What's my name?", session=session) - # Claude will remember "Alice" from the same session - - With Agent Framework tools: - - .. code-block:: python - - from agent_framework import tool - - @tool - def greet(name: str) -> str: - \"\"\"Greet someone by name.\"\"\" - return f"Hello, {name}!" - - async with RawClaudeAgent(tools=[greet]) as agent: - response = await agent.run("Greet Alice") """ AGENT_PROVIDER_NAME: ClassVar[str] = "anthropic.claude" @@ -752,7 +720,7 @@ class ClaudeAgent(AgentTelemetryLayer, RawClaudeAgent[OptionsT], Generic[Options .. code-block:: python - from agent_framework_claude import ClaudeAgent + from agent_framework.anthropic import ClaudeAgent async with ClaudeAgent( instructions="You are a helpful assistant.", diff --git a/python/packages/core/agent_framework/anthropic/__init__.py b/python/packages/core/agent_framework/anthropic/__init__.py index 242554cf16..8be2a7d208 100644 --- a/python/packages/core/agent_framework/anthropic/__init__.py +++ b/python/packages/core/agent_framework/anthropic/__init__.py @@ -11,6 +11,7 @@ - AnthropicChatOptions - ClaudeAgent - ClaudeAgentOptions +- RawClaudeAgent """ import importlib @@ -21,6 +22,7 @@ "AnthropicChatOptions": ("agent_framework_anthropic", "agent-framework-anthropic"), "ClaudeAgent": ("agent_framework_claude", "agent-framework-claude"), "ClaudeAgentOptions": ("agent_framework_claude", "agent-framework-claude"), + "RawClaudeAgent": ("agent_framework_claude", "agent-framework-claude"), }