Compare commits

..
17 changed files with 78 additions and 154 deletions
+4 -5
View File
@@ -34,15 +34,14 @@ from dataclasses import dataclass
# (e.g., "packages/core/agent_framework/observability.py")
# =============================================================================
ENFORCED_TARGETS: set[str] = {
# Packages (sorted alphabetically)
"packages.anthropic.agent_framework_anthropic",
"packages.azure-ai-search.agent_framework_azure_ai_search",
# Packages
"packages.azure-ai.agent_framework_azure_ai",
"packages.core.agent_framework",
"packages.core.agent_framework._workflows",
"packages.foundry.agent_framework_foundry",
"packages.openai.agent_framework_openai",
"packages.purview.agent_framework_purview",
"packages.anthropic.agent_framework_anthropic",
"packages.azure-ai-search.agent_framework_azure_ai_search",
"packages.openai.agent_framework_openai",
# Individual files (if you want to enforce specific files instead of whole packages)
"packages/core/agent_framework/observability.py",
# Add more targets here as coverage improves
@@ -23,7 +23,8 @@ internal sealed class StreamingRunEventStream : IRunEventStream
private readonly CancellationTokenSource _runLoopCancellation;
private readonly bool _disableRunLoop;
private Task? _runLoopTask;
private RunStatus _runStatus = RunStatus.NotStarted;
private volatile RunStatus _runStatus = RunStatus.NotStarted;
private int _completionEpoch; // Tracks which completion signal belongs to which consumer iteration
public StreamingRunEventStream(ISuperStepRunner stepRunner, bool disableRunLoop = false)
@@ -127,7 +128,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Wait for next input from the consumer
// Works for both Idle (no work) and PendingRequests (waiting for responses)
await this._inputWaiter.WaitForInputAsync(TimeSpan.FromSeconds(1), linkedSource.Token).ConfigureAwait(false);
await this._inputWaiter.WaitForInputAsync(linkedSource.Token).ConfigureAwait(false);
// When signaled, resume running
this._runStatus = RunStatus.Running;
@@ -209,7 +210,10 @@ internal sealed class StreamingRunEventStream : IRunEventStream
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Get the current epoch - we'll only respond to completion signals from this epoch or later
int myEpoch = Volatile.Read(ref this._completionEpoch) + 1;
int currentEpoch = Volatile.Read(ref this._completionEpoch);
bool expectingFreshWork = this._stepRunner.HasUnprocessedMessages || this._runStatus == RunStatus.Running;
int myEpoch = expectingFreshWork ? currentEpoch + 1 : currentEpoch;
// Use custom async enumerable to avoid exceptions on cancellation.
NonThrowingChannelReaderAsyncEnumerable<WorkflowEvent> eventStream = new(this._eventChannel.Reader);
@@ -132,6 +132,53 @@ public class InProcessExecutionTests
"both versions should produce the same number of agent events");
}
/// <summary>
/// This test checks that the logic around waiting for input and halting appropriately works right when the
/// workflow runs to halting before the EventStream is watched by the user.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWaitToTakeStreamAsync()
{
// Arrange: Create a simple agent that responds to messages
var agent = new SimpleTestAgent("test-agent");
var workflow = AgentWorkflowBuilder.BuildSequential(agent);
var inputMessage = new ChatMessage(ChatRole.User, "Hello");
// Act: Execute using streaming version with TurnToken
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, new List<ChatMessage> { inputMessage });
// Send TurnToken to actually trigger execution (this is the key step)
bool messageSent = await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
messageSent.Should().BeTrue("TurnToken should be accepted");
while (await run.GetStatusAsync() != RunStatus.Idle)
{
await Task.Delay(200);
}
// Collect events
List<WorkflowEvent> events = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert: The workflow should have executed and produced events
RunStatus status = await run.GetStatusAsync();
status.Should().Be(RunStatus.Idle, "workflow should complete execution");
events.Should().NotBeEmpty("workflow should produce events during execution");
// Check that we have agent execution events
var agentEvents = events.OfType<AgentResponseUpdateEvent>().ToList();
agentEvents.Should().NotBeEmpty("agent should have executed and produced update events");
// Check that we have output events
var outputEvents = events.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().NotBeEmpty("workflow should produce output events");
}
/// <summary>
/// Simple test agent that echoes back the input message.
/// </summary>
@@ -1271,8 +1271,8 @@ class RawAnthropicClient(
)
)
case "input_json_delta":
# Skip argument deltas for MCP and server tools — execution is handled server-side.
if self._last_call_content_type in ("mcp_tool_use", "server_tool_use"):
# Skip argument deltas for MCP tools — execution is handled server-side.
if self._last_call_content_type == "mcp_tool_use":
pass
else:
call_id = self._last_call_id_name[0] if self._last_call_id_name else ""
@@ -1123,53 +1123,6 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(
assert result[0].arguments == '"San Francisco"}'
def test_parse_contents_server_tool_use_input_json_delta_ignored(
mock_anthropic_client: MagicMock,
) -> None:
"""Regression test: input_json_delta events are ignored after a server_tool_use block.
Server-managed tools have their execution handled server-side, so streaming
input_json_delta events must not produce Content.from_function_call(name='')
entries that would cause Anthropic API 400 errors on subsequent turns.
"""
client = create_test_anthropic_client(mock_anthropic_client)
# Simulate a server_tool_use event that sets _last_call_content_type
server_tool_content = MagicMock()
server_tool_content.type = "server_tool_use"
server_tool_content.id = "srvtool_abc"
server_tool_content.name = "web_search"
server_tool_content.input = {}
result = client._parse_contents_from_anthropic([server_tool_content])
# server_tool_use falls through to function_call (not mcp_tool_use / code_execution)
assert len(result) == 1
assert result[0].type == "function_call"
assert client._last_call_content_type == "server_tool_use" # type: ignore[attr-defined]
# input_json_delta events after server_tool_use must be silently ignored
delta_content = MagicMock()
delta_content.type = "input_json_delta"
delta_content.partial_json = '{"query": "latest news"}'
result = client._parse_contents_from_anthropic([delta_content])
assert result == [], (
"input_json_delta after server_tool_use should produce no content, "
"but got: %r" % result
)
# A second delta must also be ignored
delta_content_2 = MagicMock()
delta_content_2.type = "input_json_delta"
delta_content_2.partial_json = '{"extra": true}'
result = client._parse_contents_from_anthropic([delta_content_2])
assert result == [], (
"subsequent input_json_delta after server_tool_use should also be ignored, "
"but got: %r" % result
)
# Stream Processing Tests
@@ -8,12 +8,11 @@ from typing import Annotated
from agent_framework import Message, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import enable_instrumentation
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from opentelemetry._logs import set_logger_provider
from opentelemetry.metrics import set_meter_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogRecordExporter
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
@@ -38,7 +37,7 @@ def setup_logging():
# Create and set a global logger provider for the application.
logger_provider = LoggerProvider(resource=resource)
# Log processors are initialized with an exporter which is responsible
logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogRecordExporter()))
logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter()))
# Sets the global default logger provider
set_logger_provider(logger_provider)
# Create a logging handler to write logging records, in OTLP format, to the exporter.
@@ -116,15 +115,11 @@ async def run_chat_client() -> None:
2 spans with gen_ai.operation.name=execute_tool
"""
client = FoundryChatClient(credential=AzureCliCredential())
client = FoundryChatClient()
message = "What's the weather in Amsterdam and in Paris?"
print(f"User: {message}")
print("Assistant: ", end="")
async for chunk in client.get_response(
[Message(role="user", text=message)],
stream=True,
options={"tools": [get_weather]},
):
async for chunk in client.get_response([Message(role="user", text=message)], tools=get_weather, stream=True):
if chunk.text:
print(chunk.text, end="")
print("")
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Annotated
from agent_framework import Message, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import get_tracer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from opentelemetry.trace import SpanKind
from opentelemetry.trace.span import format_trace_id
@@ -91,19 +90,12 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
async for chunk in client.get_response(
[Message(role="user", text=message)],
stream=True,
options={"tools": [get_weather]},
):
async for chunk in client.get_response([Message(role="user", text=message)], tools=get_weather, stream=True):
if chunk.text:
print(chunk.text, end="")
print("")
else:
response = await client.get_response(
[Message(role="user", text=message)],
options={"tools": [get_weather]},
)
response = await client.get_response([Message(role="user", text=message)], tools=get_weather)
print(f"Assistant: {response}")
@@ -111,7 +103,7 @@ async def main() -> None:
with get_tracer().start_as_current_span("Zero Code", kind=SpanKind.CLIENT) as current_span:
print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}")
client = FoundryChatClient(credential=AzureCliCredential())
client = FoundryChatClient()
await run_chat_client(client, stream=True)
await run_chat_client(client, stream=False)
@@ -7,7 +7,6 @@ from typing import Annotated
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import configure_otel_providers, get_tracer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from opentelemetry.trace import SpanKind
from opentelemetry.trace.span import format_trace_id
@@ -19,12 +18,6 @@ load_dotenv()
"""
This sample shows how you can observe an agent in Agent Framework by using the
same observability setup function.
Pre-requisites:
- A Foundry project
- An observability backend to receive traces and metrics (for example, a local or remote
OpenTelemetry Collector, another OTLP-compatible backend, or console exporters enabled
via environment variables).
"""
@@ -54,7 +47,7 @@ async def main():
print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}")
agent = Agent(
client=FoundryChatClient(credential=AzureCliCredential()),
client=FoundryChatClient(),
tools=get_weather,
name="WeatherAgent",
instructions="You are a weather assistant.",
@@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Annotated, Literal
from agent_framework import Message, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import configure_otel_providers, get_tracer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from opentelemetry import trace
from opentelemetry.trace.span import format_trace_id
@@ -25,9 +24,8 @@ This sample shows how you can configure observability of an application via the
When you run this sample with an OTLP endpoint or an Application Insights connection string,
you should see traces, logs, and metrics in the configured backend.
Pre-requisites:
- A Foundry project
- A local OpenTelemetry Collector instance to receive the traces and metrics.
If no OTLP endpoint or Application Insights connection string is configured, the sample will
output traces, logs, and metrics to the console.
"""
# Load environment variables from .env file
@@ -80,18 +78,13 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals
if stream:
print("Assistant: ", end="")
async for chunk in client.get_response(
[Message(role="user", text=message)],
stream=True,
options={"tools": [get_weather]},
[Message(role="user", text=message)], tools=get_weather, stream=True
):
if chunk.text:
print(chunk.text, end="")
print("")
else:
response = await client.get_response(
[Message(role="user", text=message)],
options={"tools": [get_weather]},
)
response = await client.get_response([Message(role="user", text=message)], tools=get_weather)
print(f"Assistant: {response}")
@@ -108,7 +101,7 @@ async def run_tool() -> None:
with get_tracer().start_as_current_span("Scenario: AI Function", kind=trace.SpanKind.CLIENT):
print("Running scenario: AI Function")
weather = await get_weather.invoke(location="Amsterdam")
print(f"Weather in Amsterdam:\n{weather[-1]}")
print(f"Weather in Amsterdam:\n{weather}")
async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "all"):
@@ -121,7 +114,7 @@ async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "al
with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span:
print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}")
client = FoundryChatClient(credential=AzureCliCredential())
client = FoundryChatClient()
# Scenarios where telemetry is collected in the SDK, from the most basic to the most complex.
if scenario == "tool" or scenario == "all":
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Annotated, Literal
from agent_framework import Message, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import configure_otel_providers, get_tracer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from opentelemetry import trace
from opentelemetry.trace.span import format_trace_id
@@ -28,10 +27,6 @@ and allows you to add multiple exporters programmatically.
For standard OTLP setup, it's recommended to use environment variables (see configure_otel_providers_with_env_var.py).
Use this approach when you need custom exporter configuration beyond what environment variables provide.
Pre-requisites:
- A Foundry project
- A local OpenTelemetry Collector instance to receive the traces and metrics.
"""
# Load environment variables from .env file
@@ -84,18 +79,13 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals
if stream:
print("Assistant: ", end="")
async for chunk in client.get_response(
[Message(role="user", text=message)],
stream=True,
options={"tools": [get_weather]},
[Message(role="user", text=message)], stream=True, tools=get_weather
):
if chunk.text:
print(chunk.text, end="")
print("")
else:
response = await client.get_response(
[Message(role="user", text=message)],
options={"tools": [get_weather]},
)
response = await client.get_response([Message(role="user", text=message)], tools=get_weather)
print(f"Assistant: {response}")
@@ -112,7 +102,7 @@ async def run_tool() -> None:
with get_tracer().start_as_current_span("Scenario: AI Function", kind=trace.SpanKind.CLIENT):
print("Running scenario: AI Function")
weather = await get_weather.invoke(location="Amsterdam")
print(f"Weather in Amsterdam:\n{weather[-1]}")
print(f"Weather in Amsterdam:\n{weather}")
async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "all"):
@@ -163,7 +153,7 @@ async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "al
with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span:
print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}")
client = FoundryChatClient(credential=AzureCliCredential())
client = FoundryChatClient()
# Scenarios where telemetry is collected in the SDK, from the most basic to the most complex.
if scenario == "tool" or scenario == "all":
@@ -12,9 +12,6 @@ Supported MCP server types:
- "http": Remote HTTP server
- "sse": Remote SSE (Server-Sent Events) server
Environment variables:
- ANTHROPIC_API_KEY: Your Anthropic API key
SECURITY NOTE: MCP servers can expose powerful capabilities. Only configure
servers you trust. Use permission handlers to control what actions are allowed.
"""
@@ -15,9 +15,6 @@ Available built-in tools:
- "Glob": Search for files by pattern
- "Grep": Search file contents
Environment variables:
- ANTHROPIC_API_KEY: Your Anthropic API key
SECURITY NOTE: Only enable permissions that are necessary for your use case.
More permissions mean more potential for unintended actions.
"""
@@ -27,10 +24,6 @@ from typing import Any
from agent_framework.anthropic import ClaudeAgent
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
async def prompt_permission(
@@ -6,9 +6,6 @@ Claude Agent with Session Management
This sample demonstrates session management with ClaudeAgent, showing
persistent conversation capabilities. Sessions are automatically persisted
by the Claude Code CLI.
Environment variables:
- ANTHROPIC_API_KEY: Your Anthropic API key
"""
import asyncio
@@ -17,12 +14,8 @@ from typing import Annotated
from agent_framework import tool
from agent_framework.anthropic import ClaudeAgent
from dotenv import load_dotenv
from pydantic import Field
# Load environment variables from .env file
load_dotenv()
@tool
def get_weather(
@@ -7,9 +7,6 @@ This sample demonstrates how to enable shell command execution with ClaudeAgent.
By providing a permission handler via `can_use_tool`, the agent can execute
shell commands to perform tasks like listing files, running scripts, or executing system commands.
Environment variables:
- ANTHROPIC_API_KEY: Your Anthropic API key
SECURITY NOTE: Only enable shell permissions when you trust the agent's actions.
Shell commands have full access to your system within the permissions of the running process.
"""
@@ -19,10 +16,6 @@ from typing import Any
from agent_framework.anthropic import ClaudeAgent
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
async def prompt_permission(
@@ -13,18 +13,11 @@ Available built-in tools:
- "Edit": Edit existing files
- "Glob": Search for files by pattern
- "Grep": Search file contents
Environment variables:
- ANTHROPIC_API_KEY: Your Anthropic API key
"""
import asyncio
from agent_framework.anthropic import ClaudeAgent
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
async def main() -> None:
@@ -10,9 +10,6 @@ Available web tools:
- "WebFetch": Fetch content from URLs
- "WebSearch": Search the web
Environment variables:
- ANTHROPIC_API_KEY: Your Anthropic API key
SECURITY NOTE: Only enable URL permissions when you trust the agent's actions.
URL fetching allows the agent to access any URL accessible from your network.
"""
@@ -20,10 +17,6 @@ URL fetching allows the agent to access any URL accessible from your network.
import asyncio
from agent_framework.anthropic import ClaudeAgent
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
async def main() -> None:
@@ -21,10 +21,6 @@ This sample demonstrates using Anthropic with:
You can also set additonal_chat_options with "additional_beta_flags" per request.
- Creating an agent with the Code Interpreter tool and a Skill.
- Catching and downloading generated files from the agent.
Environment variables:
- ANTHROPIC_API_KEY: Your Anthropic API key
- ANTHROPIC_CHAT_MODEL_ID: The Anthropic model to use, such as "claude-sonnet-4-5-20250929"
"""