diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index 67d99ae6252..3d005bbc391 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -10,6 +10,7 @@ import uuid from collections import OrderedDict from collections.abc import AsyncIterable, Awaitable, Mapping +from functools import partial from typing import TYPE_CHECKING, Any, TypedDict, cast from ag_ui.core import ( @@ -48,6 +49,9 @@ ) from agent_framework._types import ResponseStream from agent_framework.exceptions import AgentInvalidResponseException +from agent_framework.observability import ( + _use_telemetry_conversation_id, # pyright: ignore[reportPrivateUsage] +) from ._approval_state import _APPROVAL_SCOPE_INPUT_KEY, InMemoryAGUIApprovalStateStore, approval_state_thread_id from ._message_adapters import normalize_agui_input_messages @@ -63,6 +67,7 @@ _extract_resume_payload, # type: ignore _extract_tool_result_display, # type: ignore _has_only_tool_calls, # type: ignore + _iterate_with_context, # type: ignore _normalize_resume_interrupts, # type: ignore _reconstruct_messages_from_thread_snapshot, # type: ignore _resume_contract_error, # type: ignore @@ -1815,8 +1820,10 @@ async def run_agent_stream( AG-UI events """ # Parse IDs - thread_id = input_data.get("thread_id") or input_data.get("threadId") or str(uuid.uuid4()) - run_id = input_data.get("run_id") or input_data.get("runId") or str(uuid.uuid4()) + supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId") + supplied_run_id = input_data.get("run_id") or input_data.get("runId") + thread_id = supplied_thread_id or str(uuid.uuid4()) + run_id = supplied_run_id or str(uuid.uuid4()) snapshot_scope = cast(str | None, input_data.get(_SNAPSHOT_SCOPE_INPUT_KEY)) approval_scope = cast(str | None, input_data.get(_APPROVAL_SCOPE_INPUT_KEY)) approval_thread_id = approval_state_thread_id(scope=approval_scope, thread_id=thread_id) @@ -1959,7 +1966,6 @@ async def run_agent_stream( # Create session (with service session support) if config.use_service_session: - supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId") session = AgentSession(session_id=thread_id, service_session_id=supplied_thread_id) else: session = AgentSession(session_id=thread_id) @@ -2062,23 +2068,33 @@ async def run_agent_stream( # Stream from agent - emit RunStarted after first update to get service IDs run_started_emitted = False + provider_thread_id: str | None = None all_updates: list[Any] = [] # Collect for structured output processing latest_state_snapshot: dict[str, Any] | None = ( cast(dict[str, Any], make_json_safe(flow.current_state)) if flow.current_state else None ) - response_stream = agent.run(messages, stream=True, **run_kwargs) - stream = await _normalize_response_stream(response_stream) - async for update in stream: + # Agent middleware can defer the inner run until streaming begins, so the + # telemetry override must cover construction, stream resolution, and every pull. + telemetry_conversation_id = str(supplied_thread_id) if supplied_thread_id is not None else None + telemetry_context = partial(_use_telemetry_conversation_id, telemetry_conversation_id) + with telemetry_context(): + response_stream = agent.run(messages, stream=True, **run_kwargs) + stream = await _normalize_response_stream(response_stream) + + async for update in _iterate_with_context(stream, telemetry_context): # Collect updates for structured output processing if response_format is not None: all_updates.append(update) - # Update IDs from service response on first update and emit RunStarted + # Use service-generated IDs only when the AG-UI request omitted them. Client-supplied + # IDs remain authoritative for lifecycle correlation and thread-scoped persistence. if not run_started_emitted: conv_id = get_conversation_id_from_update(update) if conv_id: + provider_thread_id = conv_id + if supplied_thread_id is None and conv_id: thread_id = conv_id - if update.response_id: + if supplied_run_id is None and update.response_id: run_id = update.response_id # NOW emit RunStarted with proper IDs yield RunStartedEvent(run_id=run_id, thread_id=thread_id) @@ -2118,7 +2134,10 @@ async def run_agent_stream( if content_type == "function_approval_request" and pending_approvals is not None: if content.id and content.function_call and content.function_call.name: canonical_interrupt_id = content.function_call.call_id or content.id - provider_approval_thread_id = approval_state_thread_id(scope=approval_scope, thread_id=thread_id) + provider_approval_thread_id = approval_state_thread_id( + scope=approval_scope, + thread_id=provider_thread_id or thread_id, + ) _register_pending_approval( pending_approvals, [approval_thread_id, provider_approval_thread_id], diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index e59ea0941f4..ace78176a1f 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -7,9 +7,10 @@ import copy import json import logging -from collections.abc import Mapping +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping +from contextlib import AbstractContextManager from dataclasses import dataclass, field -from typing import Any, cast +from typing import Any, TypeVar, cast from ag_ui.core import ( BaseEvent, @@ -32,7 +33,7 @@ ToolCallResultEvent, ToolCallStartEvent, ) -from agent_framework import Content +from agent_framework import Content, ResponseStream from ._orchestration._predictive_state import PredictiveStateHandler from ._state import TOOL_RESULT_DISPLAY_KEY, TOOL_RESULT_STATE_KEY @@ -40,10 +41,33 @@ logger = logging.getLogger(__name__) +_StreamItemT = TypeVar("_StreamItemT") + # Sentinel for an unset display_result; distinguishes "caller didn't pass" from None/{}/"". _UNSET = object() +async def _iterate_with_context( + stream: AsyncIterable[_StreamItemT], + context_factory: Callable[[], AbstractContextManager[Any]], +) -> AsyncGenerator[_StreamItemT]: + """Advance a response stream with a fresh execution context for every pull.""" + if isinstance(stream, ResponseStream): + stream.with_pull_context_manager(context_factory) + async for item in stream: + yield item + return + + stream_iterator = aiter(stream) + while True: + with context_factory(): + try: + item = await anext(stream_iterator) + except StopAsyncIteration: + return + yield item + + def _has_only_tool_calls(contents: list[Any]) -> bool: """Check if contents have only tool calls (no text).""" has_tool_call = any(getattr(c, "type", None) == "function_call" for c in contents) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py index 3f6e76e5fd3..bd3089775ea 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py @@ -9,6 +9,7 @@ import logging import uuid from collections.abc import AsyncGenerator +from functools import partial from typing import Any, cast, get_args, get_origin from ag_ui.core import ( @@ -25,6 +26,9 @@ ToolCallStartEvent, ) from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, Workflow, WorkflowRunState +from agent_framework.observability import ( + _use_telemetry_conversation_id, # pyright: ignore[reportPrivateUsage] +) from ._message_adapters import normalize_agui_input_messages from ._run_common import ( @@ -33,6 +37,7 @@ _close_reasoning_block, _emit_content, _extract_resume_payload, + _iterate_with_context, _normalize_resume_interrupts, _resume_contract_error, ) @@ -777,7 +782,8 @@ async def run_workflow_stream( workflow: Workflow, ) -> AsyncGenerator[BaseEvent]: """Run a Workflow and emit AG-UI protocol events.""" - thread_id = input_data.get("thread_id") or input_data.get("threadId") or str(uuid.uuid4()) + supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId") + thread_id = supplied_thread_id or str(uuid.uuid4()) run_id = input_data.get("run_id") or input_data.get("runId") or str(uuid.uuid4()) available_interrupts = input_data.get("available_interrupts") or input_data.get("availableInterrupts") if available_interrupts: @@ -890,12 +896,15 @@ def _drain_open_message() -> list[TextMessageEndEvent]: fwd_kwargs = {} try: - if responses: - event_stream = workflow.run(responses=responses, stream=True, **fwd_kwargs) - else: - event_stream = workflow.run(message=messages, stream=True, **fwd_kwargs) - - async for event in event_stream: + telemetry_conversation_id = str(supplied_thread_id) if supplied_thread_id is not None else None + telemetry_context = partial(_use_telemetry_conversation_id, telemetry_conversation_id) + with telemetry_context(): + if responses: + event_stream = workflow.run(responses=responses, stream=True, **fwd_kwargs) + else: + event_stream = workflow.run(message=messages, stream=True, **fwd_kwargs) + + async for event in _iterate_with_context(event_stream, telemetry_context): event_type = getattr(event, "type", None) if event_type == "started": diff --git a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py index 0408de841d8..35ade8e90cc 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py +++ b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py @@ -15,6 +15,7 @@ from ag_ui.core import MessagesSnapshotEvent, RunStartedEvent, StateSnapshotEvent from agent_framework import ( Agent, + AgentContext, AgentResponseUpdate, AgentSession, ChatResponseUpdate, @@ -3724,6 +3725,145 @@ async def stream_fn(messages: Any, options: Any, **kwargs: Any): assert state_snapshots[0]["snapshot"] == {"recipe": "pasta"} +async def test_agent_endpoint_keeps_request_thread_key_when_provider_returns_conversation_id( + streaming_chat_client_stub: Any, +) -> None: + """A provider conversation id must not move snapshots away from the requested AG-UI thread.""" + app = FastAPI() + captured_messages: list[list[tuple[str, str]]] = [] + + async def stream_fn(messages: Any, options: Any, **kwargs: Any) -> AsyncIterator[ChatResponseUpdate]: + del options, kwargs + captured_messages.append([(message.role, message.text) for message in messages]) + yield ChatResponseUpdate( + contents=[Content.from_text(text=f"Reply {len(captured_messages)}")], + conversation_id="conv_foundry_123", + response_id=f"resp_foundry_{len(captured_messages)}", + ) + + agent = Agent(name="test", instructions="Test agent", client=streaming_chat_client_stub(stream_fn)) + store = InMemoryAGUIThreadSnapshotStore() + add_agent_framework_fastapi_endpoint( + app, + agent, + path="/snapshots", + snapshot_store=store, + snapshot_scope_resolver=lambda _request: "tenant-a", + ) + client = TestClient(app) + + first_response = client.post( + "/snapshots", + json={ + "thread_id": "ag-ui-thread-1", + "run_id": "run-1", + "messages": [{"id": "user-1", "role": "user", "content": "Remember LANTERN-482"}], + }, + ) + assert first_response.status_code == 200 + first_events = _decode_sse_events(first_response) + assert (first_events[0]["threadId"], first_events[0]["runId"]) == ("ag-ui-thread-1", "run-1") + assert (first_events[-1]["threadId"], first_events[-1]["runId"]) == ("ag-ui-thread-1", "run-1") + + second_response = client.post( + "/snapshots", + json={ + "thread_id": "ag-ui-thread-1", + "run_id": "run-2", + "messages": [{"id": "user-2", "role": "user", "content": "What token?"}], + }, + ) + + assert second_response.status_code == 200 + second_events = _decode_sse_events(second_response) + assert (second_events[0]["threadId"], second_events[0]["runId"]) == ("ag-ui-thread-1", "run-2") + assert (second_events[-1]["threadId"], second_events[-1]["runId"]) == ("ag-ui-thread-1", "run-2") + assert captured_messages[1] == [ + ("user", "Remember LANTERN-482"), + ("assistant", "Reply 1"), + ("user", "What token?"), + ] + + +async def test_agent_endpoint_correlates_agent_spans_with_supplied_thread_id( + streaming_chat_client_stub: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Agent spans use the stable AG-UI thread id as their OTel conversation id.""" + from types import SimpleNamespace + + import agent_framework.observability as observability + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + exporter = InMemorySpanExporter() + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr( + observability, + "OBSERVABILITY_SETTINGS", + SimpleNamespace(ENABLED=True, SENSITIVE_DATA_ENABLED=False), + ) + monkeypatch.setattr(observability, "get_tracer", lambda *args, **kwargs: tracer_provider.get_tracer("test")) + + call_count = 0 + + async def stream_fn(messages: Any, options: Any, **kwargs: Any) -> AsyncIterator[ChatResponseUpdate]: + nonlocal call_count + del messages, options, kwargs + call_count += 1 + yield ChatResponseUpdate( + contents=[Content.from_text(text=f"Reply {call_count}")], + conversation_id=f"resp_foundry_{call_count}", + ) + + app = FastAPI() + + async def passthrough_middleware(_context: AgentContext, call_next: Any) -> None: + await call_next() + + agent = Agent( + name="test", + instructions="Test agent", + client=streaming_chat_client_stub(stream_fn), + middleware=[passthrough_middleware], + ) + add_agent_framework_fastapi_endpoint(app, agent, path="/agent") + client = TestClient(app) + + for run_number in (1, 2): + response = client.post( + "/agent", + json={ + "thread_id": "ag-ui-thread-1", + "run_id": f"run-{run_number}", + "messages": [{"role": "user", "content": f"Turn {run_number}"}], + }, + ) + assert response.status_code == 200 + + agent_spans = [] + for span in exporter.get_finished_spans(): + if span.attributes is not None and span.attributes.get("gen_ai.operation.name") == "invoke_agent": + agent_spans.append(span) + + assert len(agent_spans) == 2 + trace_ids: set[int] = set() + conversation_ids = [] + for span in agent_spans: + assert span.context is not None + assert span.attributes is not None + trace_ids.add(span.context.trace_id) + conversation_ids.append(span.attributes.get("gen_ai.conversation.id")) + + assert len(trace_ids) == 2 + assert conversation_ids == [ + "ag-ui-thread-1", + "ag-ui-thread-1", + ] + + async def test_agent_endpoint_deduplicates_full_history_and_merges_fresh_state(streaming_chat_client_stub): """Stored prior history is authoritative while incoming full history and fresh state remain supported.""" app = FastAPI() diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py index 8af9a33be2b..a68da6c6d0f 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py @@ -8,9 +8,11 @@ from types import SimpleNamespace from typing import Any, cast -from ag_ui.core import EventType, StateSnapshotEvent +import pytest +from ag_ui.core import EventType, RunFinishedEvent, RunStartedEvent, StateSnapshotEvent from agent_framework import ( Agent, + AgentContext, AgentResponse, AgentResponseUpdate, ChatResponseUpdate, @@ -114,6 +116,122 @@ async def start(message: Any, ctx: WorkflowContext[Any, str]) -> None: assert custom_events[0].value == {"progress": 10} # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] +async def test_workflow_and_agent_spans_use_supplied_agui_thread_id(monkeypatch: pytest.MonkeyPatch) -> None: + """Workflow spans use supplied AG-UI threads without replacing provider fallback behavior.""" + import agent_framework.observability as observability + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + exporter = InMemorySpanExporter() + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr( + observability, + "OBSERVABILITY_SETTINGS", + SimpleNamespace(ENABLED=True, SENSITIVE_DATA_ENABLED=False), + ) + monkeypatch.setattr(observability, "get_tracer", lambda *args, **kwargs: tracer_provider.get_tracer("test")) + + call_count = 0 + + async def scripted_stream( + messages: Any, + options: Any, + **kwargs: Any, + ) -> AsyncIterator[ChatResponseUpdate]: + nonlocal call_count + del messages, options, kwargs + call_count += 1 + yield ChatResponseUpdate( + contents=[Content.from_text(text=f"Reply {call_count}")], + conversation_id="provider-conversation", + ) + + async def passthrough_middleware(_context: AgentContext, call_next: Any) -> None: + await call_next() + + participant = Agent( + client=StreamingChatClientStub(scripted_stream), + name="workflow-agent", + middleware=[passthrough_middleware], + ) + workflow = WorkflowBuilder(start_executor=participant, output_from="all").build() + + for run_number in (1, 2): + events = [ + event + async for event in run_workflow_stream( + { + "thread_id": "ag-ui-workflow-thread", + "run_id": f"run-{run_number}", + "messages": [{"role": "user", "content": f"Turn {run_number}"}], + }, + workflow, + ) + ] + run_started = next(event for event in events if isinstance(event, RunStartedEvent)) + run_finished = next(event for event in events if isinstance(event, RunFinishedEvent)) + assert (run_started.thread_id, run_started.run_id) == ( + "ag-ui-workflow-thread", + f"run-{run_number}", + ) + assert (run_finished.thread_id, run_finished.run_id) == ( + "ag-ui-workflow-thread", + f"run-{run_number}", + ) + + agent_spans = [] + for span in exporter.get_finished_spans(): + if span.attributes is not None and span.attributes.get("gen_ai.operation.name") == "invoke_agent": + agent_spans.append(span) + + assert len(agent_spans) == 2 + conversation_ids = [] + for span in agent_spans: + assert span.attributes is not None + conversation_ids.append(span.attributes.get("gen_ai.conversation.id")) + + assert conversation_ids == [ + "ag-ui-workflow-thread", + "ag-ui-workflow-thread", + ] + + workflow_spans = [span for span in exporter.get_finished_spans() if span.name == "workflow.run"] + assert len(workflow_spans) == 2 + workflow_conversation_ids = [] + for span in workflow_spans: + assert span.attributes is not None + workflow_conversation_ids.append(span.attributes.get("gen_ai.conversation.id")) + + assert workflow_conversation_ids == [ + "ag-ui-workflow-thread", + "ag-ui-workflow-thread", + ] + + exporter.clear() + events = [ + event + async for event in run_workflow_stream( + {"messages": [{"role": "user", "content": "Provider fallback"}]}, + workflow, + ) + ] + assert any(isinstance(event, RunFinishedEvent) for event in events) + + fallback_agent_span = next( + span + for span in exporter.get_finished_spans() + if span.attributes is not None and span.attributes.get("gen_ai.operation.name") == "invoke_agent" + ) + assert fallback_agent_span.attributes is not None + assert fallback_agent_span.attributes.get("gen_ai.conversation.id") == "provider-conversation" + + fallback_workflow_span = next(span for span in exporter.get_finished_spans() if span.name == "workflow.run") + assert fallback_workflow_span.attributes is not None + assert "gen_ai.conversation.id" not in fallback_workflow_span.attributes + + async def test_workflow_run_request_info_emits_interrupt_and_resume_works(): """request_info should emit interrupt metadata and resume should continue run.""" diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 20629b76b81..cd120a2fd8c 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -127,6 +127,29 @@ "inner_accumulated_usage", default=None ) +# Allows protocol adapters to supply an application-managed conversation identity for one execution +# without putting that value into a service-owned continuation field. +_TELEMETRY_CONVERSATION_ID: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar( + "telemetry_conversation_id", default=None +) + + +@contextlib.contextmanager +def _use_telemetry_conversation_id( # pyright: ignore[reportUnusedFunction] + conversation_id: str | None, +) -> Generator[None]: + """Set an application-managed OTel conversation id for the current execution.""" + if conversation_id is None: + yield + return + + token = _TELEMETRY_CONVERSATION_ID.set(conversation_id) + try: + yield + finally: + _TELEMETRY_CONVERSATION_ID.reset(token) + + OTEL_METRICS: Final[str] = "__otel_metrics__" TOKEN_USAGE_BUCKET_BOUNDARIES: Final[tuple[float, ...]] = ( 1, @@ -1824,11 +1847,13 @@ def _trace_agent_invocation( "Callable[[AgentSession | None], str | None] | None", getattr(self, "_get_otel_conversation_id", None), ) - conversation_id = ( - get_otel_conversation_id(session) - if callable(get_otel_conversation_id) - else (session.service_session_id if (session and isinstance(session.service_session_id, str)) else None) - ) + conversation_id = _TELEMETRY_CONVERSATION_ID.get() + if conversation_id is None: + conversation_id = ( + get_otel_conversation_id(session) + if callable(get_otel_conversation_id) + else (session.service_session_id if (session and isinstance(session.service_session_id, str)) else None) + ) attributes = _get_span_attributes( operation_name=OtelAttr.AGENT_INVOKE_OPERATION, provider_name=provider_name, @@ -2903,7 +2928,11 @@ def create_workflow_span( kind: trace.SpanKind = trace.SpanKind.INTERNAL, ) -> _AgnosticContextManager[trace.Span]: """Create a generic workflow span.""" - return workflow_tracer().start_as_current_span(name, kind=kind, attributes=attributes) + span_attributes = dict(attributes) if attributes is not None else {} + conversation_id = _TELEMETRY_CONVERSATION_ID.get() + if name == OtelAttr.WORKFLOW_RUN_SPAN and conversation_id is not None: + span_attributes.setdefault(OtelAttr.CONVERSATION_ID, conversation_id) + return workflow_tracer().start_as_current_span(name, kind=kind, attributes=span_attributes or None) def create_processing_span( diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 53e99dc1395..c0e0710248b 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -608,6 +608,36 @@ class MockChatClientAgent(AgentTelemetryLayer, _MockChatClientAgent): # type: i return MockChatClientAgent +async def test_agent_telemetry_conversation_override_is_scoped( + mock_chat_agent: SupportsAgentRun, + span_exporter: InMemorySpanExporter, +) -> None: + """An application-managed conversation id overrides provider continuation for one run only.""" + from agent_framework import AgentSession + from agent_framework.observability import ( + _use_telemetry_conversation_id, # pyright: ignore[reportPrivateUsage] + ) + + agent = mock_chat_agent() # type: ignore[operator] # pyrefly: ignore[not-callable] # ty: ignore[call-non-callable] + session = AgentSession(service_session_id="provider-conversation") + span_exporter.clear() + + with _use_telemetry_conversation_id("application-thread"): + await agent.run("First turn", session=session) + await agent.run("Second turn", session=session) + + spans = span_exporter.get_finished_spans() + conversation_ids = [] + for span in spans: + assert span.attributes is not None + conversation_ids.append(span.attributes.get(OtelAttr.CONVERSATION_ID)) + + assert conversation_ids == [ + "application-thread", + "provider-conversation", + ] + + @pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) async def test_agent_span_captures_response_telemetry_without_inner_chat_span( mock_chat_agent: SupportsAgentRun, span_exporter: InMemorySpanExporter, enable_sensitive_data @@ -2081,6 +2111,46 @@ def test_create_workflow_span(span_exporter): assert spans[0].attributes["key"] == "value" +def test_create_workflow_span_uses_scoped_conversation_id(span_exporter: InMemorySpanExporter) -> None: + """An ambient conversation id is applied only within its workflow execution scope.""" + from agent_framework.observability import ( + OtelAttr, + _use_telemetry_conversation_id, # pyright: ignore[reportPrivateUsage] + create_workflow_span, + ) + + span_exporter.clear() # type: ignore[attr-defined] + with _use_telemetry_conversation_id("application-thread"): + with create_workflow_span(OtelAttr.WORKFLOW_RUN_SPAN): + pass + with create_workflow_span( + OtelAttr.WORKFLOW_RUN_SPAN, + attributes={OtelAttr.CONVERSATION_ID: "explicit-thread"}, + ): + pass + with create_workflow_span(OtelAttr.MESSAGE_SEND_SPAN): + pass + with create_workflow_span(OtelAttr.WORKFLOW_RUN_SPAN): + pass + + spans = span_exporter.get_finished_spans() # type: ignore[attr-defined] + workflow_spans = [span for span in spans if span.name == OtelAttr.WORKFLOW_RUN_SPAN] + assert len(workflow_spans) == 3 + ambient_attributes = workflow_spans[0].attributes + explicit_attributes = workflow_spans[1].attributes + unscoped_attributes = workflow_spans[2].attributes + assert ambient_attributes is not None + assert explicit_attributes is not None + assert unscoped_attributes is not None + assert ambient_attributes[OtelAttr.CONVERSATION_ID] == "application-thread" + assert explicit_attributes[OtelAttr.CONVERSATION_ID] == "explicit-thread" + assert OtelAttr.CONVERSATION_ID not in unscoped_attributes + message_send_span = next(span for span in spans if span.name == OtelAttr.MESSAGE_SEND_SPAN) + message_send_attributes = message_send_span.attributes + assert message_send_attributes is not None + assert OtelAttr.CONVERSATION_ID not in message_send_attributes + + def test_create_processing_span(span_exporter): """Test create_processing_span creates a span with correct attributes.""" from agent_framework.observability import OtelAttr, create_processing_span diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/README.md b/python/samples/05-end-to-end/ag_ui_single_agent/README.md new file mode 100644 index 00000000000..b1d2bfb1ff6 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/README.md @@ -0,0 +1,96 @@ +# AG-UI Single Agent Demo + +The simplest possible AG-UI integration: a **single chat agent** with **no tools** and **no context providers**, +served over the AG-UI protocol and consumed by a small React client. + +Use this sample as the starting point for AG-UI. For a richer, multi-agent example with tool-approval checkpoints +and human-in-the-loop resumes, see [`../ag_ui_workflow_handoff`](../ag_ui_workflow_handoff/README.md). + +## Folder Layout + +- `backend/server.py` - FastAPI + AG-UI endpoint wrapping a single `Agent` +- `frontend/` - Vite + React AG-UI client UI + +## Prerequisites + +- Python 3.10+ +- Node.js 20.19+ or 22.12+ +- npm 9+ +- Azure AI project + model deployment configured in environment variables: + - `FOUNDRY_PROJECT_ENDPOINT` + - `FOUNDRY_MODEL` +- Azure CLI authenticated with `az login` + +## 1) Run Backend + +From the repository root: + +```bash +cd python +uv sync +uv run python samples/05-end-to-end/ag_ui_single_agent/backend/server.py +``` + +Backend default URL: + +- `http://127.0.0.1:8892` +- AG-UI endpoint: `POST http://127.0.0.1:8892/agent` + +To export traces to the Application Insights resource connected to the Foundry project, run the backend with: + +```bash +ENABLE_AZURE_MONITOR=true uv run python samples/05-end-to-end/ag_ui_single_agent/backend/server.py +``` + +Each user turn is a separate run and trace. The stable AG-UI `thread_id` is recorded as +`gen_ai.conversation.id`, which lets Foundry group those turns into one conversation. + +## 2) Install Frontend Packages (npm) + +From the `python/` directory (where Step 1 left you): + +```bash +cd samples/05-end-to-end/ag_ui_single_agent/frontend +npm install +``` + +## 3) Run Frontend Locally + +```bash +npm run dev +``` + +Frontend default URL: + +- `http://127.0.0.1:5173` + +If you changed backend host/port, run with: + +```bash +VITE_BACKEND_URL=http://127.0.0.1:8892 npm run dev +``` + +## 4) Demo Flow to Verify + +1. Click one of the starter prompts (or type your own message). +2. Watch the assistant response stream in token by token. +3. Send a follow-up that depends on the previous turn (for example: "summarize what you just told me"). + The client only sends the newest message plus the `thread_id`; the server replays the stored history. +4. Click **New Thread** to start a fresh conversation (a new `thread_id`). + +## Conversation History + +The client only ever sends the **newest message** plus a `thread_id`. The backend retains history **server-side**, +keyed by that `thread_id`, using an `InMemoryAGUIThreadSnapshotStore`. Because an AG-UI thread id is not an +authorization boundary, a `snapshot_scope_resolver` is required whenever a snapshot store is configured; this +single-tenant demo maps every request to one shared `"demo"` scope. + +The in-memory store is process-local and not durable. Swap in your own `AGUIThreadSnapshotStore` implementation +(and a real scope resolver) for production. + +## What This Validates + +- `add_agent_framework_fastapi_endpoint(...)` with a plain `Agent` (no `AgentFrameworkWorkflow` wrapper) +- Streaming assistant text via `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` AG-UI events +- Server-side conversation history keyed by `thread_id` via a snapshot store +- Foundry trace correlation across runs using the stable AG-UI `thread_id` diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/backend/server.py b/python/samples/05-end-to-end/ag_ui_single_agent/backend/server.py new file mode 100644 index 00000000000..a399a0372bb --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/backend/server.py @@ -0,0 +1,148 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-ag-ui", +# "agent-framework-foundry", +# "azure-identity", +# "azure-monitor-opentelemetry", +# "fastapi", +# "python-dotenv", +# "uvicorn", +# ] +# /// + +# Copyright (c) Microsoft. All rights reserved. + +"""AG-UI single-agent demo backend. + +This sample exposes one Foundry-backed Agent over AG-UI and pairs it with the +React frontend in `../frontend`. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT: Microsoft Foundry project endpoint. + FOUNDRY_MODEL: Model deployment name. + ENABLE_AZURE_MONITOR: Set to true to export traces to the project's Application Insights resource. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from agent_framework import Agent +from agent_framework.ag_ui import ( + InMemoryAGUIThreadSnapshotStore, + add_agent_framework_fastapi_endpoint, +) +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +load_dotenv() + +logger = logging.getLogger(__name__) + + +# 1. Create one Foundry-backed agent with no tools or context providers. +def create_client() -> FoundryChatClient: + """Create the Foundry chat client used by the sample.""" + + return FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + + +def create_agent(client: FoundryChatClient) -> Agent: + """Create a single chat agent with no tools and no context providers.""" + + return Agent( + id="assistant", + name="assistant", + instructions="You are a helpful, concise assistant. Answer the user's questions directly.", + client=client, + ) + + +# 2. Configure the AG-UI endpoint, thread history, and optional trace export. +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + + client = create_client() + agent = create_agent(client) + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + if os.getenv("ENABLE_AZURE_MONITOR", "false").casefold() in {"1", "true", "yes", "on"}: + await client.configure_azure_monitor() + logger.info("Azure Monitor telemetry export is enabled") + yield + + app = FastAPI(title="AG-UI Single Agent Demo", lifespan=lifespan) + + cors_origins = [ + origin.strip() for origin in os.getenv("CORS_ORIGINS", "http://127.0.0.1:5173").split(",") if origin.strip() + ] + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + add_agent_framework_fastapi_endpoint( + app=app, + agent=agent, + path="/agent", + # Persist conversation history server-side, keyed by thread_id, so the + # client only ever sends the newest message plus its thread_id. + snapshot_store=InMemoryAGUIThreadSnapshotStore(), + # AG-UI thread ids are not an authorization boundary, so a scope is required + # when a snapshot store is configured. This demo is single-tenant, so every + # request maps to one shared scope. + snapshot_scope_resolver=lambda _request: "demo", + ) + + @app.get("/healthz") + async def healthz() -> dict[str, str]: + return {"status": "ok"} + + return app + + +app = create_app() + + +# 3. Run the backend for the React frontend. +async def main() -> None: + """Run the AG-UI single-agent demo backend.""" + + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") + + host = os.getenv("HOST", "127.0.0.1") + port = int(os.getenv("PORT", "8892")) + + print(f"AG-UI single-agent demo backend running at http://{host}:{port}") + print("AG-UI endpoint: POST /agent") + + server = uvicorn.Server(uvicorn.Config(app, host=host, port=port)) + await server.serve() + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output: +AG-UI single-agent demo backend running at http://127.0.0.1:8892 +AG-UI endpoint: POST /agent +""" diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/.gitignore b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/.gitignore new file mode 100644 index 00000000000..16c69217c02 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/.gitignore @@ -0,0 +1,7 @@ +# dependencies +/node_modules + +# build artifacts +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/index.html b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/index.html new file mode 100644 index 00000000000..fa3da04d3eb --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AG-UI Single Agent Demo + + +
+ + + diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/package-lock.json b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/package-lock.json new file mode 100644 index 00000000000..883f8129f76 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/package-lock.json @@ -0,0 +1,1054 @@ +{ + "name": "ag-ui-single-agent-demo-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ag-ui-single-agent-demo-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "^5.5.4", + "vite": "^8.0.16" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha1-ueEGTzprFjHiQeY460jXNr/TcqY=", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha1-WPHz1dgamxL3k6tojJY3GQECfCQ=", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha1-TJO+z1v6OxPRu9zAau44MhrYE5o=", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha1-7TOAbQ+b6Y3HbQw9T9hy/acBtdU=", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha1-ONdrnb+TTCoCvhdPsyzuvxgv50I=", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha1-9Yy5oKgSjtBYIoJyBShUf8XANfM=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha1-RBFEwFpKgxqnUmmrw6SjJDdOpwc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha1-yC4wZSzvUsSvkl1cZsiVWkAxmBY=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha1-wy6c5/ocD7K4CROio6BcPpB9BrA=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha1-zpC14iMWretQLqAQWC9JjNBgTyc=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha1-kZRxEMTdqk7vsATlJogHCXcIUgE=", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha1-6zKy1BCMHHArkejN6KBD6uXKqU0=", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha1-zLlDwR5acmVcuwL8EWNUHOtkB4I=", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha1-ozcx7lZ+kLdfrG5eVTB+iiswOPA=", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha1-p0wBqqzt/BHDm2/rozpfoMZUlJ8=", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha1-KM0XhJT6HmXbpBIim1zlXE3Vy9E=", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha1-98dfqRP8IIhNJqfUiNT1xZfNccA=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha1-w3lYGUd4cIHfNj6hBhQPzV/sJS0=", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha1-8jyIaU96cpoS85UCSu4434Cha6M=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha1-M3fI3g5WqIV/ESF1YRRwkOh0y0Y=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha1-4/zuCT+7XOdl4a0Ij/TeKIn2+b4=", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha1-AVy6np3UfOFNA9KoxdVHv7FpZl0=", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha1-hOfN9jzaogwTSqMXzMkBqiHhbw4=", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha1-5uWobWAr6spxzlFj+t9fldcJMcc=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha1-teleKP/M6rjZgvM/LrB24XZTwqQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha1-uJ3fLNg7T+r8xOLqQa/fuVoNGU8=", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha1-o1g0um5Gi8rWheyNBKU78M68wv8=", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha1-7EjA8+mT5QZIyG2lWeJhCZXPmJo=", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha1-aJxdzcGQDvVYOky59te0c3QgdK0=", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha1-7Sq5Z6MxreYvGNB32uGSaE1Q01A=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha1-GSA/tZmR35jjoocFDUZHzerzJJk=", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha1-wIhn1xp5OFxuGQIU/XL+8+X5Xws=", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha1-mmhB+IrlD8g1ApA4krQa9BvCuQc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha1-wPLDHAv9GfpN0/GOlXofGhUgl9Y=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha1-ywcFllrLU4xmg5Sc5pJfs833w2E=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha1-djU4gosmurJoDa2vzITueLDrUCs=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha1-aGLjF2ozGu297B7TUrTX0N0HhN4=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha1-xqOi7RUUHa9r3CYokw+OOb30c6o=", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha1-f6EzSXH8goRfmCffbvigsgkUusY=", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha1-i5J4YuqMK7xoMaRlCSRLUNmTblU=", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha1-DFJbsHff2UQEwFnP5C2teX6Wrq8=", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha1-hQ7hED2smJz6tQ46wi0aaeOU5j0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha1-40OuFS7tNgncbhGUnRo785oclG8=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha1-ce5R+nvkyuwaY4OffmgtgTLTDK8=", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha1-oE2OxLHxAAnS1TOUeu/kKTc3gWw=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha1-UepXoX2G9gX4EDlZX7xA7QalX6s=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha1-CGoNVoWnFaz1lQ67yxU4+vMFFpc=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha1-SauJIAnFOTNiW9FrJTP8dUyrKJE=", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha1-wiZdeVEbV9R5s90/36UVNklMXLQ=", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha1-M5quJQhENR/FW3TiZS0+vW+6OJ0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha1-QUumSjsoKJLpRM8hCOzAeNEVzcM=", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha1-ViqabJ6ys7Ej05cZ+a9btE/NdjE=", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha1-YS7+TtI11Wfoq6Xypfq3AoCt6D8=", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha1-zP/OPuSHsYhiI7JOuye5FnSCfTA=", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/package.json b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/package.json new file mode 100644 index 00000000000..e0eee696588 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "ag-ui-single-agent-demo-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "^5.5.4", + "vite": "^8.0.16" + } +} diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/App.tsx b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/App.tsx new file mode 100644 index 00000000000..3ce6419b7ea --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/App.tsx @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; + +type AgUiEvent = Record & { type: string }; + +interface ChatMessage { + id: string; + role: "assistant" | "user" | "system"; + text: string; +} + +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "http://127.0.0.1:8892"; +const ENDPOINT = `${BACKEND_URL}/agent`; + +const STARTER_PROMPTS = [ + "Explain the AG-UI protocol in two sentences.", + "Give me three tips for writing clear commit messages.", +]; + +function randomId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `id-${Math.random().toString(16).slice(2)}`; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function safeParseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +export default function App() { + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [isRunning, setIsRunning] = useState(false); + const [statusText, setStatusText] = useState("Ready"); + + const threadIdRef = useRef(randomId()); + const streamingMessageIdRef = useRef(null); + const transcriptRef = useRef(null); + + const canSend = useMemo(() => draft.trim().length > 0 && !isRunning, [draft, isRunning]); + + useEffect(() => { + const node = transcriptRef.current; + if (node) { + node.scrollTop = node.scrollHeight; + } + }, [messages]); + + const pushMessage = (message: ChatMessage): void => { + setMessages((prev) => [...prev, message]); + }; + + const appendToStreamingMessage = (messageId: string, delta: string): void => { + setMessages((prev) => { + const existing = prev.find((message) => message.id === messageId); + if (existing) { + return prev.map((message) => + message.id === messageId ? { ...message, text: `${message.text}${delta}` } : message, + ); + } + return [...prev, { id: messageId, role: "assistant", text: delta }]; + }); + }; + + const handleEvent = (event: AgUiEvent): void => { + switch (event.type) { + case "RUN_STARTED": + setStatusText("Thinking"); + break; + case "TEXT_MESSAGE_START": { + const messageId = typeof event.messageId === "string" ? event.messageId : randomId(); + streamingMessageIdRef.current = messageId; + break; + } + case "TEXT_MESSAGE_CONTENT": { + const messageId = + typeof event.messageId === "string" ? event.messageId : streamingMessageIdRef.current ?? randomId(); + const delta = typeof event.delta === "string" ? event.delta : ""; + if (delta.length > 0) { + setStatusText("Responding"); + appendToStreamingMessage(messageId, delta); + } + break; + } + case "TEXT_MESSAGE_END": + streamingMessageIdRef.current = null; + break; + case "RUN_FINISHED": + setStatusText("Ready"); + setIsRunning(false); + break; + case "RUN_ERROR": { + const errorText = typeof event.message === "string" ? event.message : "The run failed."; + pushMessage({ id: randomId(), role: "system", text: `Error: ${errorText}` }); + setStatusText("Error"); + setIsRunning(false); + break; + } + default: + break; + } + }; + + const streamRun = async (body: Record): Promise => { + const response = await fetch(ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify(body), + }); + + if (!response.ok || !response.body) { + throw new Error(`Request failed: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + + const processSseChunk = (rawChunk: string): void => { + const dataLines = rawChunk + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()); + + if (dataLines.length === 0) { + return; + } + + const parsed = safeParseJson(dataLines.join("\n")); + if (isObject(parsed) && typeof parsed.type === "string") { + handleEvent(parsed as AgUiEvent); + } + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + while (true) { + const boundary = /\r?\n\r?\n/.exec(buffer); + if (boundary === null) { + break; + } + const boundaryIndex = boundary.index; + const rawEvent = buffer.slice(0, boundaryIndex); + buffer = buffer.slice(boundaryIndex + boundary[0].length); + processSseChunk(rawEvent); + } + } + + const tail = buffer.trim(); + if (tail.length > 0) { + processSseChunk(tail); + } + }; + + const sendMessage = async (text: string): Promise => { + const trimmed = text.trim(); + if (trimmed.length === 0 || isRunning) { + return; + } + + pushMessage({ id: randomId(), role: "user", text: trimmed }); + setDraft(""); + setIsRunning(true); + setStatusText("Connecting"); + streamingMessageIdRef.current = null; + + try { + await streamRun({ + thread_id: threadIdRef.current, + run_id: randomId(), + messages: [{ role: "user", content: trimmed }], + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + pushMessage({ id: randomId(), role: "system", text: `Network error: ${message}` }); + setStatusText("Network error"); + setIsRunning(false); + } + }; + + const handleSubmit = (event: FormEvent): void => { + event.preventDefault(); + void sendMessage(draft); + }; + + const startNewThread = (): void => { + threadIdRef.current = randomId(); + streamingMessageIdRef.current = null; + setMessages([]); + setDraft(""); + setStatusText("Ready"); + setIsRunning(false); + }; + + return ( +
+
+
+

Agent Framework ยท AG-UI

+

Single Agent Chat

+

+ The simplest AG-UI integration: one chat agent with no tools and no context providers, streamed to a React + client over Server-Sent Events. +

+
+
+ Status + {statusText} +
+
+ +
+
+

Conversation

+ +
+ +
+ {messages.length === 0 ? ( +
+

Start the conversation with a prompt:

+
+ {STARTER_PROMPTS.map((prompt) => ( + + ))} +
+
+ ) : ( + messages.map((message) => ( +
+ {message.role} +

{message.text}

+
+ )) + )} +
+ +
+ setDraft(event.target.value)} + disabled={isRunning} + /> + +
+
+
+ ); +} diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/main.tsx b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/main.tsx new file mode 100644 index 00000000000..4daf5e19b35 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/main.tsx @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from "react"; +import ReactDOM from "react-dom/client"; + +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/styles.css b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/styles.css new file mode 100644 index 00000000000..adab4d7f456 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/styles.css @@ -0,0 +1,259 @@ +/* Copyright (c) Microsoft. All rights reserved. */ + +:root { + --page-bg: #edf4f8; + --panel-bg: #fdfdfd; + --ink: #132534; + --muted: #607487; + --line: #c6d6e2; + --teal: #1f9d8b; + --teal-dark: #11756a; + --shadow: 0 20px 45px rgb(15 35 51 / 14%); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "IBM Plex Sans", "Avenir Next", "Helvetica Neue", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at 12% 8%, rgb(31 157 139 / 20%) 0%, transparent 28%), + radial-gradient(circle at 88% 18%, rgb(255 154 60 / 20%) 0%, transparent 30%), + linear-gradient(150deg, #eff6fa 0%, #dceaf3 46%, #e7f1f6 100%); +} + +.page-shell { + min-height: 100vh; + max-width: 860px; + margin: 0 auto; + padding: 28px; + animation: fade-in 320ms ease-out; +} + +.hero { + display: flex; + gap: 20px; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 24px; +} + +.eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; + color: var(--teal-dark); + font-weight: 700; +} + +.hero h1 { + margin: 6px 0 8px; + font-size: clamp(1.6rem, 2.8vw, 2.4rem); + line-height: 1.15; +} + +.subtitle { + margin: 0; + max-width: 60ch; + color: var(--muted); + line-height: 1.45; +} + +.status-pill { + border: 1px solid var(--line); + border-radius: 999px; + padding: 10px 16px; + background: #fff; + display: flex; + flex-direction: column; + min-width: 150px; + box-shadow: 0 8px 20px rgb(19 37 52 / 8%); +} + +.status-pill span { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.status-pill strong { + font-size: 1rem; +} + +.status-pill[data-running="true"] { + border-color: var(--teal); +} + +.card { + background: var(--panel-bg); + border: 1px solid var(--line); + border-radius: 18px; + box-shadow: var(--shadow); + padding: 18px; +} + +.chat-card { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 60vh; +} + +.chat-toolbar { + display: flex; + align-items: center; + justify-content: space-between; +} + +.chat-toolbar h2 { + margin: 0; + font-size: 1.1rem; +} + +.ghost-button { + border: 1px solid var(--line); + background: #fff; + color: var(--teal-dark); + border-radius: 999px; + padding: 6px 14px; + font-weight: 600; + cursor: pointer; +} + +.ghost-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.transcript { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 6px 2px; + max-height: 52vh; +} + +.empty-state { + color: var(--muted); + display: grid; + gap: 12px; +} + +.starter-prompts { + display: grid; + gap: 10px; +} + +.starter-prompt { + text-align: left; + border: 1px dashed var(--line); + background: #f6fafc; + border-radius: 12px; + padding: 12px 14px; + color: var(--ink); + cursor: pointer; +} + +.starter-prompt:hover:not(:disabled) { + border-color: var(--teal); +} + +.starter-prompt:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.bubble { + border-radius: 14px; + padding: 10px 14px; + max-width: 82%; + border: 1px solid var(--line); + background: #fff; +} + +.bubble p { + margin: 4px 0 0; + white-space: pre-wrap; + line-height: 1.45; +} + +.bubble-role { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.bubble-user { + align-self: flex-end; + background: var(--teal); + border-color: var(--teal-dark); + color: #fff; +} + +.bubble-user .bubble-role { + color: rgb(255 255 255 / 80%); +} + +.bubble-assistant { + align-self: flex-start; +} + +.bubble-system { + align-self: center; + background: #fff4e6; + border-color: #ffcf99; + color: #8a5200; + max-width: 100%; +} + +.composer { + display: flex; + gap: 10px; +} + +.composer input { + flex: 1; + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px 14px; + font-size: 1rem; +} + +.composer input:focus { + outline: none; + border-color: var(--teal); +} + +.send-button { + border: none; + background: var(--teal); + color: #fff; + border-radius: 12px; + padding: 12px 22px; + font-weight: 700; + cursor: pointer; +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/vite-env.d.ts b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/vite-env.d.ts new file mode 100644 index 00000000000..948b8a3ecfd --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/src/vite-env.d.ts @@ -0,0 +1,3 @@ +// Copyright (c) Microsoft. All rights reserved. + +/// diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/tsconfig.json b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/tsconfig.json new file mode 100644 index 00000000000..d1faad8f5f1 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/tsconfig.node.json b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/tsconfig.node.json new file mode 100644 index 00000000000..6c68264f0b3 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2020", + "lib": ["ES2020"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "types": ["node"], + "skipLibCheck": true + }, + "include": ["vite.config.ts"] +} diff --git a/python/samples/05-end-to-end/ag_ui_single_agent/frontend/vite.config.ts b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/vite.config.ts new file mode 100644 index 00000000000..e8620a6bb68 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_single_agent/frontend/vite.config.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + host: "127.0.0.1", + port: 5173, + }, +}); diff --git a/python/samples/README.md b/python/samples/README.md index 4a585db7813..769a12e6f64 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -10,7 +10,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen | [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations | | [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative, explicit output designation | | [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A | -| [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos | +| [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos, including the [AG-UI single-agent demo](./05-end-to-end/ag_ui_single_agent/) using `FoundryChatClient` | ## Getting Started