remdb 0.3.200__py3-none-any.whl → 0.3.226__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

rem/agentic/README.md CHANGED
@@ -716,11 +716,271 @@ curl -X POST http://localhost:8000/api/v1/chat/completions \
716
716
 
717
717
  See `rem/api/README.md` for full SSE event protocol documentation.
718
718
 
719
+ ## Multi-Agent Orchestration
720
+
721
+ REM supports hierarchical agent orchestration where agents can delegate work to other agents via the `ask_agent` tool. This enables complex workflows with specialized agents.
722
+
723
+ ### Architecture
724
+
725
+ ```mermaid
726
+ sequenceDiagram
727
+ participant User
728
+ participant API as Chat API
729
+ participant Orchestrator as Orchestrator Agent
730
+ participant EventSink as Event Sink (Queue)
731
+ participant Child as Child Agent
732
+ participant DB as PostgreSQL
733
+
734
+ User->>API: POST /chat/completions (stream=true)
735
+ API->>API: Create event sink (asyncio.Queue)
736
+ API->>Orchestrator: agent.iter(prompt)
737
+
738
+ loop Streaming Loop
739
+ Orchestrator->>API: PartDeltaEvent (text)
740
+ API->>User: SSE: data: {"delta": {"content": "..."}}
741
+ end
742
+
743
+ Orchestrator->>Orchestrator: Decides to call ask_agent
744
+ Orchestrator->>API: ToolCallPart (ask_agent)
745
+ API->>User: SSE: event: tool_call
746
+
747
+ API->>Child: ask_agent("child_name", input)
748
+ Child->>EventSink: push_event(child_content)
749
+ EventSink->>API: Consume child events
750
+ API->>User: SSE: data: {"delta": {"content": "..."}}
751
+
752
+ Child->>Child: Completes
753
+ Child-->>Orchestrator: Return result
754
+
755
+ Orchestrator->>API: Final response
756
+ API->>DB: Save tool calls
757
+ API->>DB: Save assistant message
758
+ API->>User: SSE: data: [DONE]
759
+ ```
760
+
761
+ ### Event Sink Pattern
762
+
763
+ When an agent delegates to a child via `ask_agent`, the child's streaming events need to bubble up to the parent's stream. This is achieved through an **event sink** pattern using Python's `ContextVar`:
764
+
765
+ ```python
766
+ # context.py
767
+ from contextvars import ContextVar
768
+
769
+ _parent_event_sink: ContextVar["asyncio.Queue | None"] = ContextVar(
770
+ "parent_event_sink", default=None
771
+ )
772
+
773
+ async def push_event(event: Any) -> bool:
774
+ """Push event to parent's event sink if available."""
775
+ sink = _parent_event_sink.get()
776
+ if sink is not None:
777
+ await sink.put(event)
778
+ return True
779
+ return False
780
+ ```
781
+
782
+ The streaming controller sets up the event sink before agent execution:
783
+
784
+ ```python
785
+ # streaming.py
786
+ child_event_sink: asyncio.Queue = asyncio.Queue()
787
+ set_event_sink(child_event_sink)
788
+
789
+ async for node in agent.iter(prompt):
790
+ # Process agent events...
791
+
792
+ # Consume any child events that arrived
793
+ while not child_event_sink.empty():
794
+ child_event = child_event_sink.get_nowait()
795
+ if child_event["type"] == "child_content":
796
+ yield format_sse_content_delta(child_event["content"])
797
+ ```
798
+
799
+ ### ask_agent Tool Implementation
800
+
801
+ The `ask_agent` tool in `mcp_router/tools.py` uses Pydantic AI's streaming iteration:
802
+
803
+ ```python
804
+ async def ask_agent(agent_name: str, input_text: str, ...):
805
+ """Delegate work to another agent."""
806
+
807
+ # Load and create child agent
808
+ schema = await load_agent_schema_async(agent_name, user_id)
809
+ child_agent = await create_agent(context=context, agent_schema_override=schema)
810
+
811
+ # Stream child agent with event proxying
812
+ async with child_agent.iter(prompt) as agent_run:
813
+ async for node in agent_run:
814
+ if Agent.is_model_request_node(node):
815
+ async with node.stream(agent_run.ctx) as request_stream:
816
+ async for event in request_stream:
817
+ if isinstance(event, PartDeltaEvent):
818
+ # Push content to parent's event sink
819
+ await push_event({
820
+ "type": "child_content",
821
+ "agent_name": agent_name,
822
+ "content": event.delta.content_delta,
823
+ })
824
+
825
+ return agent_run.result
826
+ ```
827
+
828
+ ### Pydantic AI Features Used
829
+
830
+ #### 1. Streaming Iteration (`agent.iter()`)
831
+
832
+ Unlike `agent.run()` which blocks until completion, `agent.iter()` provides fine-grained control over the execution flow:
833
+
834
+ ```python
835
+ async with agent.iter(prompt) as agent_run:
836
+ async for node in agent_run:
837
+ if Agent.is_model_request_node(node):
838
+ # Model is generating - stream the response
839
+ async with node.stream(agent_run.ctx) as stream:
840
+ async for event in stream:
841
+ if isinstance(event, PartStartEvent):
842
+ # Tool call starting
843
+ elif isinstance(event, PartDeltaEvent):
844
+ # Content chunk
845
+ elif Agent.is_call_tools_node(node):
846
+ # Tools are being executed
847
+ async with node.stream(agent_run.ctx) as stream:
848
+ async for event in stream:
849
+ if isinstance(event, FunctionToolResultEvent):
850
+ # Tool completed
851
+ ```
852
+
853
+ #### 2. Node Types
854
+
855
+ - **`ModelRequestNode`**: The model is generating a response (text or tool calls)
856
+ - **`CallToolsNode`**: Tools are being executed
857
+ - **`End`**: Agent execution complete
858
+
859
+ #### 3. Event Types
860
+
861
+ - **`PartStartEvent`**: A new part (text or tool call) is starting
862
+ - **`PartDeltaEvent`**: Content chunk for streaming text
863
+ - **`FunctionToolResultEvent`**: Tool execution completed with result
864
+ - **`ToolCallPart`**: Metadata about a tool call (name, arguments)
865
+ - **`TextPart`**: Text content
866
+
867
+ ### Message Persistence
868
+
869
+ All messages are persisted to PostgreSQL for session continuity:
870
+
871
+ ```python
872
+ # streaming.py - after agent completes
873
+ async def save_session_messages(...):
874
+ store = SessionMessageStore(user_id=user_id)
875
+
876
+ # Save each tool call as a tool message
877
+ for tool_call in tool_calls:
878
+ await store.save_message(
879
+ session_id=session_id,
880
+ role="tool",
881
+ content=tool_call.result,
882
+ tool_name=tool_call.name,
883
+ tool_call_id=tool_call.id,
884
+ )
885
+
886
+ # Save the final assistant response
887
+ await store.save_message(
888
+ session_id=session_id,
889
+ role="assistant",
890
+ content=accumulated_content,
891
+ )
892
+ ```
893
+
894
+ Messages are stored with:
895
+ - **Embeddings**: For semantic search across conversation history
896
+ - **Compression**: Long conversations are summarized to manage context window
897
+ - **Session isolation**: Each session maintains its own message history
898
+
899
+ ### Testing Multi-Agent Systems
900
+
901
+ #### Integration Tests
902
+
903
+ Real end-to-end tests without mocking are in `tests/integration/test_ask_agent_streaming.py`:
904
+
905
+ ```python
906
+ class TestAskAgentStreaming:
907
+ async def test_ask_agent_streams_and_saves(self, session_id, user_id):
908
+ """Test delegation via ask_agent."""
909
+ # Uses test_orchestrator which always delegates to test_responder
910
+ agent = await create_agent(context=context, agent_schema_override=schema)
911
+
912
+ chunks = []
913
+ async for chunk in stream_openai_response_with_save(
914
+ agent=agent,
915
+ prompt="Hello, please delegate this",
916
+ ...
917
+ ):
918
+ chunks.append(chunk)
919
+
920
+ # Verify streaming worked
921
+ assert len(content_chunks) > 0
922
+
923
+ # Verify persistence
924
+ messages = await store.load_session_messages(session_id)
925
+ assert len([m for m in messages if m["role"] == "assistant"]) == 1
926
+ assert len([m for m in messages if m["tool_name"] == "ask_agent"]) >= 1
927
+
928
+ async def test_multi_turn_saves_all_assistant_messages(self, session_id, user_id):
929
+ """Test that each turn saves its own assistant message.
930
+
931
+ This catches scoping bugs like accumulated_content not being
932
+ properly scoped per-turn.
933
+ """
934
+ turn_prompts = [
935
+ "Hello, how are you?",
936
+ "Tell me something interesting",
937
+ "Thanks for chatting!",
938
+ ]
939
+
940
+ for prompt in turn_prompts:
941
+ async for chunk in stream_openai_response_with_save(...):
942
+ pass
943
+
944
+ # Each turn should save an assistant message
945
+ messages = await store.load_session_messages(session_id)
946
+ assistant_msgs = [m for m in messages if m["role"] == "assistant"]
947
+ assert len(assistant_msgs) == 3
948
+ ```
949
+
950
+ #### Test Agent Schemas
951
+
952
+ Test agents are defined in `tests/data/schemas/agents/`:
953
+
954
+ - **`test_orchestrator.yaml`**: Always delegates via `ask_agent`
955
+ - **`test_responder.yaml`**: Simple agent that responds directly
956
+
957
+ ```yaml
958
+ # test_orchestrator.yaml
959
+ type: object
960
+ description: |
961
+ You are a TEST ORCHESTRATOR that ALWAYS delegates to another agent.
962
+ Call ask_agent with agent_name="test_responder" on EVERY turn.
963
+ json_schema_extra:
964
+ kind: agent
965
+ name: test_orchestrator
966
+ tools:
967
+ - name: ask_agent
968
+ mcp_server: rem
969
+ ```
970
+
971
+ #### Running Integration Tests
972
+
973
+ ```bash
974
+ # Run individually (recommended due to async isolation)
975
+ POSTGRES__CONNECTION_STRING="postgresql://rem:rem@localhost:5050/rem" \
976
+ uv run pytest tests/integration/test_ask_agent_streaming.py::TestAskAgentStreaming::test_multi_turn_saves_all_assistant_messages -v -s
977
+ ```
978
+
719
979
  ## Future Work
720
980
 
721
981
  - [ ] Phoenix evaluator integration
722
982
  - [ ] Agent schema registry (load schemas by URI)
723
983
  - [ ] Schema validation and versioning
724
- - [ ] Multi-turn conversation management
725
- - [ ] Agent composition (agents calling agents)
984
+ - [x] Multi-turn conversation management
985
+ - [x] Agent composition (agents calling agents)
726
986
  - [ ] Alternative provider implementations (if needed)
rem/agentic/context.py CHANGED
@@ -30,9 +30,10 @@ Multi-Agent Context Propagation:
30
30
  - Child agents inherit user_id, tenant_id, session_id, is_eval from parent
31
31
  """
32
32
 
33
+ import asyncio
33
34
  from contextlib import contextmanager
34
35
  from contextvars import ContextVar
35
- from typing import Generator
36
+ from typing import Any, Generator
36
37
 
37
38
  from loguru import logger
38
39
  from pydantic import BaseModel, Field
@@ -46,6 +47,13 @@ _current_agent_context: ContextVar["AgentContext | None"] = ContextVar(
46
47
  "current_agent_context", default=None
47
48
  )
48
49
 
50
+ # Event sink for streaming child agent events to parent
51
+ # When set, child agents (via ask_agent) should push their events here
52
+ # for the parent's streaming loop to proxy to the client
53
+ _parent_event_sink: ContextVar["asyncio.Queue | None"] = ContextVar(
54
+ "parent_event_sink", default=None
55
+ )
56
+
49
57
 
50
58
  def get_current_context() -> "AgentContext | None":
51
59
  """
@@ -97,6 +105,70 @@ def agent_context_scope(ctx: "AgentContext") -> Generator["AgentContext", None,
97
105
  _current_agent_context.set(previous)
98
106
 
99
107
 
108
+ # =============================================================================
109
+ # Event Sink for Streaming Multi-Agent Delegation
110
+ # =============================================================================
111
+
112
+
113
+ def get_event_sink() -> "asyncio.Queue | None":
114
+ """
115
+ Get the parent's event sink for streaming child events.
116
+
117
+ Used by ask_agent to push child agent events to the parent's stream.
118
+ Returns None if not in a streaming context.
119
+ """
120
+ return _parent_event_sink.get()
121
+
122
+
123
+ def set_event_sink(sink: "asyncio.Queue | None") -> None:
124
+ """Set the event sink for child agents to push events to."""
125
+ _parent_event_sink.set(sink)
126
+
127
+
128
+ @contextmanager
129
+ def event_sink_scope(sink: "asyncio.Queue") -> Generator["asyncio.Queue", None, None]:
130
+ """
131
+ Context manager for scoped event sink setting.
132
+
133
+ Used by streaming layer to set up event proxying before tool execution.
134
+ Child agents (via ask_agent) will push their events to this sink.
135
+
136
+ Example:
137
+ event_queue = asyncio.Queue()
138
+ with event_sink_scope(event_queue):
139
+ # ask_agent will push child events to event_queue
140
+ async for event in tools_stream:
141
+ ...
142
+ # Also consume from event_queue
143
+ """
144
+ previous = _parent_event_sink.get()
145
+ _parent_event_sink.set(sink)
146
+ try:
147
+ yield sink
148
+ finally:
149
+ _parent_event_sink.set(previous)
150
+
151
+
152
+ async def push_event(event: Any) -> bool:
153
+ """
154
+ Push an event to the parent's event sink (if available).
155
+
156
+ Used by ask_agent to proxy child agent events to the parent's stream.
157
+ Returns True if event was pushed, False if no sink available.
158
+
159
+ Args:
160
+ event: Any streaming event (ToolCallEvent, content chunk, etc.)
161
+
162
+ Returns:
163
+ True if event was pushed to sink, False otherwise
164
+ """
165
+ sink = _parent_event_sink.get()
166
+ if sink is not None:
167
+ await sink.put(event)
168
+ return True
169
+ return False
170
+
171
+
100
172
  class AgentContext(BaseModel):
101
173
  """
102
174
  Session and configuration context for agent execution.
@@ -116,7 +116,7 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
116
116
  the artificial MCP distinction between tools and resources.
117
117
 
118
118
  Supports both:
119
- - Concrete URIs: "rem://schemas" -> tool with no parameters
119
+ - Concrete URIs: "rem://agents" -> tool with no parameters
120
120
  - Template URIs: "patient-profile://field/{field_key}" -> tool with field_key parameter
121
121
 
122
122
  Args:
@@ -131,7 +131,7 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
131
131
 
132
132
  Example:
133
133
  # Concrete URI -> no-param tool
134
- tool = create_resource_tool("rem://schemas", "List all agent schemas")
134
+ tool = create_resource_tool("rem://agents", "List all agent schemas")
135
135
 
136
136
  # Template URI -> parameterized tool
137
137
  tool = create_resource_tool("patient-profile://field/{field_key}", "Get field definition", mcp_server=mcp)
@@ -732,7 +732,7 @@ async def create_agent(
732
732
  # the artificial MCP distinction between tools and resources
733
733
  #
734
734
  # Supports both concrete and template URIs:
735
- # - Concrete: "rem://schemas" -> no-param tool
735
+ # - Concrete: "rem://agents" -> no-param tool
736
736
  # - Template: "patient-profile://field/{field_key}" -> tool with field_key param
737
737
  from ..mcp.tool_wrapper import create_resource_tool
738
738
 
rem/agentic/schema.py CHANGED
@@ -79,7 +79,7 @@ class MCPResourceReference(BaseModel):
79
79
 
80
80
  Example (exact URI):
81
81
  {
82
- "uri": "rem://schemas",
82
+ "uri": "rem://agents",
83
83
  "name": "Agent Schemas",
84
84
  "description": "List all available agent schemas"
85
85
  }
@@ -96,7 +96,7 @@ class MCPResourceReference(BaseModel):
96
96
  default=None,
97
97
  description=(
98
98
  "Exact resource URI or URI with query parameters. "
99
- "Examples: 'rem://schemas', 'rem://resources?category=drug.*'"
99
+ "Examples: 'rem://agents', 'rem://resources?category=drug.*'"
100
100
  )
101
101
  )
102
102
 
@@ -594,15 +594,18 @@ async def read_resource(uri: str) -> dict[str, Any]:
594
594
  **Available Resources:**
595
595
 
596
596
  Agent Schemas:
597
- • rem://schemas - List all agent schemas
598
- • rem://schema/{name} - Get specific schema definition
599
- • rem://schema/{name}/{version} - Get specific version
597
+ • rem://agents - List all available agent schemas
598
+ • rem://agents/{agent_name} - Get specific agent schema
599
+
600
+ Documentation:
601
+ • rem://schema/entities - Entity schemas (Resource, Message, User, File, Moment)
602
+ • rem://schema/query-types - REM query type documentation
600
603
 
601
604
  System Status:
602
605
  • rem://status - System health and statistics
603
606
 
604
607
  Args:
605
- uri: Resource URI (e.g., "rem://schemas", "rem://schema/ask_rem")
608
+ uri: Resource URI (e.g., "rem://agents", "rem://agents/ask_rem")
606
609
 
607
610
  Returns:
608
611
  Dict with:
@@ -611,14 +614,11 @@ async def read_resource(uri: str) -> dict[str, Any]:
611
614
  - data: Resource data (format depends on resource type)
612
615
 
613
616
  Examples:
614
- # List all schemas
615
- read_resource(uri="rem://schemas")
616
-
617
- # Get specific schema
618
- read_resource(uri="rem://schema/ask_rem")
617
+ # List all agents
618
+ read_resource(uri="rem://agents")
619
619
 
620
- # Get schema version
621
- read_resource(uri="rem://schema/ask_rem/v1.0.0")
620
+ # Get specific agent
621
+ read_resource(uri="rem://agents/ask_rem")
622
622
 
623
623
  # Check system status
624
624
  read_resource(uri="rem://status")
@@ -1265,7 +1265,7 @@ async def ask_agent(
1265
1265
  """
1266
1266
  import asyncio
1267
1267
  from ...agentic import create_agent
1268
- from ...agentic.context import get_current_context, agent_context_scope
1268
+ from ...agentic.context import get_current_context, agent_context_scope, get_event_sink, push_event
1269
1269
  from ...agentic.agents.agent_manager import get_agent
1270
1270
  from ...utils.schema_loader import load_agent_schema
1271
1271
 
@@ -1342,16 +1342,146 @@ async def ask_agent(
1342
1342
  if input_data:
1343
1343
  prompt = f"{input_text}\n\nInput data: {json.dumps(input_data)}"
1344
1344
 
1345
+ # Load session history for the sub-agent (CRITICAL for multi-turn conversations)
1346
+ # Sub-agents need to see the full conversation context, not just the summary
1347
+ pydantic_message_history = None
1348
+ if child_context.session_id and settings.postgres.enabled:
1349
+ try:
1350
+ from ...services.session import SessionMessageStore, session_to_pydantic_messages
1351
+ from ...agentic.schema import get_system_prompt
1352
+
1353
+ store = SessionMessageStore(user_id=child_context.user_id or "default")
1354
+ raw_session_history = await store.load_session_messages(
1355
+ session_id=child_context.session_id,
1356
+ user_id=child_context.user_id,
1357
+ compress_on_load=False, # Need full data for reconstruction
1358
+ )
1359
+ if raw_session_history:
1360
+ # Extract agent's system prompt from schema
1361
+ agent_system_prompt = get_system_prompt(schema) if schema else None
1362
+ pydantic_message_history = session_to_pydantic_messages(
1363
+ raw_session_history,
1364
+ system_prompt=agent_system_prompt,
1365
+ )
1366
+ logger.debug(
1367
+ f"ask_agent '{agent_name}': loaded {len(raw_session_history)} session messages "
1368
+ f"-> {len(pydantic_message_history)} pydantic-ai messages"
1369
+ )
1370
+
1371
+ # Audit session history if enabled
1372
+ from ...services.session import audit_session_history
1373
+ audit_session_history(
1374
+ session_id=child_context.session_id,
1375
+ agent_name=agent_name,
1376
+ prompt=prompt,
1377
+ raw_session_history=raw_session_history,
1378
+ pydantic_messages_count=len(pydantic_message_history),
1379
+ )
1380
+ except Exception as e:
1381
+ logger.warning(f"ask_agent '{agent_name}': failed to load session history: {e}")
1382
+ # Fall back to running without history
1383
+
1345
1384
  # Run agent with timeout and context propagation
1346
1385
  logger.info(f"Invoking agent '{agent_name}' with prompt: {prompt[:100]}...")
1347
1386
 
1387
+ # Check if we have an event sink for streaming
1388
+ push_event = get_event_sink()
1389
+ use_streaming = push_event is not None
1390
+
1391
+ streamed_content = "" # Track if content was streamed
1392
+
1348
1393
  try:
1349
1394
  # Set child context for nested tool calls
1350
1395
  with agent_context_scope(child_context):
1351
- result = await asyncio.wait_for(
1352
- agent_runtime.run(prompt),
1353
- timeout=timeout_seconds
1354
- )
1396
+ if use_streaming:
1397
+ # STREAMING MODE: Use iter() and proxy events to parent
1398
+ logger.debug(f"ask_agent '{agent_name}': using streaming mode with event proxying")
1399
+
1400
+ async def run_with_streaming():
1401
+ from pydantic_ai.messages import (
1402
+ PartStartEvent, PartDeltaEvent, PartEndEvent,
1403
+ FunctionToolResultEvent, FunctionToolCallEvent,
1404
+ )
1405
+ from pydantic_ai.agent import Agent
1406
+
1407
+ accumulated_content = []
1408
+ child_tool_calls = []
1409
+
1410
+ # iter() returns an async context manager, not an awaitable
1411
+ iter_kwargs = {"message_history": pydantic_message_history} if pydantic_message_history else {}
1412
+ async with agent_runtime.iter(prompt, **iter_kwargs) as agent_run:
1413
+ async for node in agent_run:
1414
+ if Agent.is_model_request_node(node):
1415
+ async with node.stream(agent_run.ctx) as request_stream:
1416
+ async for event in request_stream:
1417
+ # Proxy part starts
1418
+ if isinstance(event, PartStartEvent):
1419
+ from pydantic_ai.messages import ToolCallPart, TextPart
1420
+ if isinstance(event.part, ToolCallPart):
1421
+ # Push tool start event to parent
1422
+ await push_event.put({
1423
+ "type": "child_tool_start",
1424
+ "agent_name": agent_name,
1425
+ "tool_name": event.part.tool_name,
1426
+ "arguments": event.part.args if hasattr(event.part, 'args') else None,
1427
+ })
1428
+ child_tool_calls.append({
1429
+ "tool_name": event.part.tool_name,
1430
+ "index": event.index,
1431
+ })
1432
+ elif isinstance(event.part, TextPart):
1433
+ # TextPart may have initial content
1434
+ if event.part.content:
1435
+ accumulated_content.append(event.part.content)
1436
+ await push_event.put({
1437
+ "type": "child_content",
1438
+ "agent_name": agent_name,
1439
+ "content": event.part.content,
1440
+ })
1441
+ # Proxy text content deltas to parent for real-time streaming
1442
+ elif isinstance(event, PartDeltaEvent):
1443
+ if hasattr(event, 'delta') and hasattr(event.delta, 'content_delta'):
1444
+ content = event.delta.content_delta
1445
+ if content:
1446
+ accumulated_content.append(content)
1447
+ # Push content chunk to parent for streaming
1448
+ await push_event.put({
1449
+ "type": "child_content",
1450
+ "agent_name": agent_name,
1451
+ "content": content,
1452
+ })
1453
+
1454
+ elif Agent.is_call_tools_node(node):
1455
+ async with node.stream(agent_run.ctx) as tools_stream:
1456
+ async for tool_event in tools_stream:
1457
+ if isinstance(tool_event, FunctionToolResultEvent):
1458
+ result_content = tool_event.result.content if hasattr(tool_event.result, 'content') else tool_event.result
1459
+ # Push tool result to parent
1460
+ await push_event.put({
1461
+ "type": "child_tool_result",
1462
+ "agent_name": agent_name,
1463
+ "result": result_content,
1464
+ })
1465
+
1466
+ # Get final result (inside context manager)
1467
+ return agent_run.result, "".join(accumulated_content), child_tool_calls
1468
+
1469
+ result, streamed_content, tool_calls = await asyncio.wait_for(
1470
+ run_with_streaming(),
1471
+ timeout=timeout_seconds
1472
+ )
1473
+ else:
1474
+ # NON-STREAMING MODE: Use run() for backwards compatibility
1475
+ if pydantic_message_history:
1476
+ result = await asyncio.wait_for(
1477
+ agent_runtime.run(prompt, message_history=pydantic_message_history),
1478
+ timeout=timeout_seconds
1479
+ )
1480
+ else:
1481
+ result = await asyncio.wait_for(
1482
+ agent_runtime.run(prompt),
1483
+ timeout=timeout_seconds
1484
+ )
1355
1485
  except asyncio.TimeoutError:
1356
1486
  return {
1357
1487
  "status": "error",
@@ -1365,14 +1495,20 @@ async def ask_agent(
1365
1495
 
1366
1496
  logger.info(f"Agent '{agent_name}' completed successfully")
1367
1497
 
1368
- return {
1498
+ response = {
1369
1499
  "status": "success",
1370
1500
  "output": output,
1371
- "text_response": str(result.output),
1372
1501
  "agent_schema": agent_name,
1373
1502
  "input_text": input_text,
1374
1503
  }
1375
1504
 
1505
+ # Only include text_response if content was NOT streamed
1506
+ # When streaming, child_content events already delivered the content
1507
+ if not use_streaming or not streamed_content:
1508
+ response["text_response"] = str(result.output)
1509
+
1510
+ return response
1511
+
1376
1512
 
1377
1513
  # =============================================================================
1378
1514
  # Test/Debug Tools (for development only)