agno 2.3.1__py3-none-any.whl → 2.3.3__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.
Files changed (75) hide show
  1. agno/agent/agent.py +514 -186
  2. agno/compression/__init__.py +3 -0
  3. agno/compression/manager.py +176 -0
  4. agno/db/dynamo/dynamo.py +11 -0
  5. agno/db/firestore/firestore.py +5 -1
  6. agno/db/gcs_json/gcs_json_db.py +5 -2
  7. agno/db/in_memory/in_memory_db.py +5 -2
  8. agno/db/json/json_db.py +5 -1
  9. agno/db/migrations/manager.py +4 -4
  10. agno/db/mongo/async_mongo.py +158 -34
  11. agno/db/mongo/mongo.py +6 -2
  12. agno/db/mysql/mysql.py +48 -54
  13. agno/db/postgres/async_postgres.py +61 -51
  14. agno/db/postgres/postgres.py +42 -50
  15. agno/db/redis/redis.py +5 -0
  16. agno/db/redis/utils.py +5 -5
  17. agno/db/schemas/memory.py +7 -5
  18. agno/db/singlestore/singlestore.py +99 -108
  19. agno/db/sqlite/async_sqlite.py +32 -30
  20. agno/db/sqlite/sqlite.py +34 -30
  21. agno/knowledge/reader/pdf_reader.py +2 -2
  22. agno/knowledge/reader/tavily_reader.py +0 -1
  23. agno/memory/__init__.py +14 -1
  24. agno/memory/manager.py +223 -8
  25. agno/memory/strategies/__init__.py +15 -0
  26. agno/memory/strategies/base.py +67 -0
  27. agno/memory/strategies/summarize.py +196 -0
  28. agno/memory/strategies/types.py +37 -0
  29. agno/models/anthropic/claude.py +84 -80
  30. agno/models/aws/bedrock.py +38 -16
  31. agno/models/aws/claude.py +97 -277
  32. agno/models/azure/ai_foundry.py +8 -4
  33. agno/models/base.py +101 -14
  34. agno/models/cerebras/cerebras.py +18 -7
  35. agno/models/cerebras/cerebras_openai.py +4 -2
  36. agno/models/cohere/chat.py +8 -4
  37. agno/models/google/gemini.py +578 -20
  38. agno/models/groq/groq.py +18 -5
  39. agno/models/huggingface/huggingface.py +17 -6
  40. agno/models/ibm/watsonx.py +16 -6
  41. agno/models/litellm/chat.py +17 -7
  42. agno/models/message.py +19 -5
  43. agno/models/meta/llama.py +20 -4
  44. agno/models/mistral/mistral.py +8 -4
  45. agno/models/ollama/chat.py +17 -6
  46. agno/models/openai/chat.py +17 -6
  47. agno/models/openai/responses.py +23 -9
  48. agno/models/vertexai/claude.py +99 -5
  49. agno/os/interfaces/agui/router.py +1 -0
  50. agno/os/interfaces/agui/utils.py +97 -57
  51. agno/os/router.py +16 -1
  52. agno/os/routers/memory/memory.py +146 -0
  53. agno/os/routers/memory/schemas.py +26 -0
  54. agno/os/schema.py +21 -6
  55. agno/os/utils.py +134 -10
  56. agno/run/base.py +2 -1
  57. agno/run/workflow.py +1 -1
  58. agno/team/team.py +571 -225
  59. agno/tools/mcp/mcp.py +1 -1
  60. agno/utils/agent.py +119 -1
  61. agno/utils/dttm.py +33 -0
  62. agno/utils/models/ai_foundry.py +9 -2
  63. agno/utils/models/claude.py +12 -5
  64. agno/utils/models/cohere.py +9 -2
  65. agno/utils/models/llama.py +9 -2
  66. agno/utils/models/mistral.py +4 -2
  67. agno/utils/print_response/agent.py +37 -2
  68. agno/utils/print_response/team.py +52 -0
  69. agno/utils/tokens.py +41 -0
  70. agno/workflow/types.py +2 -2
  71. {agno-2.3.1.dist-info → agno-2.3.3.dist-info}/METADATA +45 -40
  72. {agno-2.3.1.dist-info → agno-2.3.3.dist-info}/RECORD +75 -68
  73. {agno-2.3.1.dist-info → agno-2.3.3.dist-info}/WHEEL +0 -0
  74. {agno-2.3.1.dist-info → agno-2.3.3.dist-info}/licenses/LICENSE +0 -0
  75. {agno-2.3.1.dist-info → agno-2.3.3.dist-info}/top_level.txt +0 -0
@@ -302,19 +302,22 @@ class OpenAIChat(Model):
302
302
  cleaned_dict = {k: v for k, v in model_dict.items() if v is not None}
303
303
  return cleaned_dict
304
304
 
305
- def _format_message(self, message: Message) -> Dict[str, Any]:
305
+ def _format_message(self, message: Message, compress_tool_results: bool = False) -> Dict[str, Any]:
306
306
  """
307
307
  Format a message into the format expected by OpenAI.
308
308
 
309
309
  Args:
310
310
  message (Message): The message to format.
311
+ compress_tool_results: Whether to compress tool results.
311
312
 
312
313
  Returns:
313
314
  Dict[str, Any]: The formatted message.
314
315
  """
316
+ tool_result = message.get_content(use_compressed_content=compress_tool_results)
317
+
315
318
  message_dict: Dict[str, Any] = {
316
319
  "role": self.role_map[message.role] if self.role_map else self.default_role_map[message.role],
317
- "content": message.content,
320
+ "content": tool_result,
318
321
  "name": message.name,
319
322
  "tool_call_id": message.tool_call_id,
320
323
  "tool_calls": message.tool_calls,
@@ -374,6 +377,7 @@ class OpenAIChat(Model):
374
377
  tools: Optional[List[Dict[str, Any]]] = None,
375
378
  tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
376
379
  run_response: Optional[Union[RunOutput, TeamRunOutput]] = None,
380
+ compress_tool_results: bool = False,
377
381
  ) -> ModelResponse:
378
382
  """
379
383
  Send a chat completion request to the OpenAI API and parse the response.
@@ -384,6 +388,7 @@ class OpenAIChat(Model):
384
388
  response_format (Optional[Union[Dict, Type[BaseModel]]]): The response format to use.
385
389
  tools (Optional[List[Dict[str, Any]]]): The tools to use.
386
390
  tool_choice (Optional[Union[str, Dict[str, Any]]]): The tool choice to use.
391
+ compress_tool_results: Whether to compress tool results.
387
392
 
388
393
  Returns:
389
394
  ModelResponse: The chat completion response from the API.
@@ -396,7 +401,7 @@ class OpenAIChat(Model):
396
401
 
397
402
  provider_response = self.get_client().chat.completions.create(
398
403
  model=self.id,
399
- messages=[self._format_message(m) for m in messages], # type: ignore
404
+ messages=[self._format_message(m, compress_tool_results) for m in messages], # type: ignore
400
405
  **self.get_request_params(
401
406
  response_format=response_format, tools=tools, tool_choice=tool_choice, run_response=run_response
402
407
  ),
@@ -454,6 +459,7 @@ class OpenAIChat(Model):
454
459
  tools: Optional[List[Dict[str, Any]]] = None,
455
460
  tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
456
461
  run_response: Optional[Union[RunOutput, TeamRunOutput]] = None,
462
+ compress_tool_results: bool = False,
457
463
  ) -> ModelResponse:
458
464
  """
459
465
  Sends an asynchronous chat completion request to the OpenAI API.
@@ -464,6 +470,7 @@ class OpenAIChat(Model):
464
470
  response_format (Optional[Union[Dict, Type[BaseModel]]]): The response format to use.
465
471
  tools (Optional[List[Dict[str, Any]]]): The tools to use.
466
472
  tool_choice (Optional[Union[str, Dict[str, Any]]]): The tool choice to use.
473
+ compress_tool_results: Whether to compress tool results.
467
474
 
468
475
  Returns:
469
476
  ModelResponse: The chat completion response from the API.
@@ -475,7 +482,7 @@ class OpenAIChat(Model):
475
482
  assistant_message.metrics.start_timer()
476
483
  response = await self.get_async_client().chat.completions.create(
477
484
  model=self.id,
478
- messages=[self._format_message(m) for m in messages], # type: ignore
485
+ messages=[self._format_message(m, compress_tool_results) for m in messages], # type: ignore
479
486
  **self.get_request_params(
480
487
  response_format=response_format, tools=tools, tool_choice=tool_choice, run_response=run_response
481
488
  ),
@@ -533,12 +540,14 @@ class OpenAIChat(Model):
533
540
  tools: Optional[List[Dict[str, Any]]] = None,
534
541
  tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
535
542
  run_response: Optional[Union[RunOutput, TeamRunOutput]] = None,
543
+ compress_tool_results: bool = False,
536
544
  ) -> Iterator[ModelResponse]:
537
545
  """
538
546
  Send a streaming chat completion request to the OpenAI API.
539
547
 
540
548
  Args:
541
549
  messages (List[Message]): A list of messages to send to the model.
550
+ compress_tool_results: Whether to compress tool results.
542
551
 
543
552
  Returns:
544
553
  Iterator[ModelResponse]: An iterator of model responses.
@@ -552,7 +561,7 @@ class OpenAIChat(Model):
552
561
 
553
562
  for chunk in self.get_client().chat.completions.create(
554
563
  model=self.id,
555
- messages=[self._format_message(m) for m in messages], # type: ignore
564
+ messages=[self._format_message(m, compress_tool_results) for m in messages], # type: ignore
556
565
  stream=True,
557
566
  stream_options={"include_usage": True},
558
567
  **self.get_request_params(
@@ -609,12 +618,14 @@ class OpenAIChat(Model):
609
618
  tools: Optional[List[Dict[str, Any]]] = None,
610
619
  tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
611
620
  run_response: Optional[Union[RunOutput, TeamRunOutput]] = None,
621
+ compress_tool_results: bool = False,
612
622
  ) -> AsyncIterator[ModelResponse]:
613
623
  """
614
624
  Sends an asynchronous streaming chat completion request to the OpenAI API.
615
625
 
616
626
  Args:
617
627
  messages (List[Message]): A list of messages to send to the model.
628
+ compress_tool_results: Whether to compress tool results.
618
629
 
619
630
  Returns:
620
631
  Any: An asynchronous iterator of model responses.
@@ -628,7 +639,7 @@ class OpenAIChat(Model):
628
639
 
629
640
  async_stream = await self.get_async_client().chat.completions.create(
630
641
  model=self.id,
631
- messages=[self._format_message(m) for m in messages], # type: ignore
642
+ messages=[self._format_message(m, compress_tool_results) for m in messages], # type: ignore
632
643
  stream=True,
633
644
  stream_options={"include_usage": True},
634
645
  **self.get_request_params(
@@ -395,12 +395,15 @@ class OpenAIResponses(Model):
395
395
 
396
396
  return formatted_tools
397
397
 
398
- def _format_messages(self, messages: List[Message]) -> List[Union[Dict[str, Any], ResponseReasoningItem]]:
398
+ def _format_messages(
399
+ self, messages: List[Message], compress_tool_results: bool = False
400
+ ) -> List[Union[Dict[str, Any], ResponseReasoningItem]]:
399
401
  """
400
402
  Format a message into the format expected by OpenAI.
401
403
 
402
404
  Args:
403
405
  messages (List[Message]): The message to format.
406
+ compress_tool_results: Whether to compress tool results.
404
407
 
405
408
  Returns:
406
409
  Dict[str, Any]: The formatted message.
@@ -445,7 +448,7 @@ class OpenAIResponses(Model):
445
448
  if message.role in ["user", "system"]:
446
449
  message_dict: Dict[str, Any] = {
447
450
  "role": self.role_map[message.role],
448
- "content": message.content,
451
+ "content": message.get_content(use_compressed_content=compress_tool_results),
449
452
  }
450
453
  message_dict = {k: v for k, v in message_dict.items() if v is not None}
451
454
 
@@ -469,7 +472,9 @@ class OpenAIResponses(Model):
469
472
 
470
473
  # Tool call result
471
474
  elif message.role == "tool":
472
- if message.tool_call_id and message.content is not None:
475
+ tool_result = message.get_content(use_compressed_content=compress_tool_results)
476
+
477
+ if message.tool_call_id and tool_result is not None:
473
478
  function_call_id = message.tool_call_id
474
479
  # Normalize: if a fc_* id was provided, translate to its corresponding call_* id
475
480
  if isinstance(function_call_id, str) and function_call_id in fc_id_to_call_id:
@@ -477,7 +482,7 @@ class OpenAIResponses(Model):
477
482
  else:
478
483
  call_id_value = function_call_id
479
484
  formatted_messages.append(
480
- {"type": "function_call_output", "call_id": call_id_value, "output": message.content}
485
+ {"type": "function_call_output", "call_id": call_id_value, "output": tool_result}
481
486
  )
482
487
  # Tool Calls
483
488
  elif message.tool_calls is not None and len(message.tool_calls) > 0:
@@ -519,6 +524,7 @@ class OpenAIResponses(Model):
519
524
  tools: Optional[List[Dict[str, Any]]] = None,
520
525
  tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
521
526
  run_response: Optional[RunOutput] = None,
527
+ compress_tool_results: bool = False,
522
528
  ) -> ModelResponse:
523
529
  """
524
530
  Send a request to the OpenAI Responses API.
@@ -535,7 +541,7 @@ class OpenAIResponses(Model):
535
541
 
536
542
  provider_response = self.get_client().responses.create(
537
543
  model=self.id,
538
- input=self._format_messages(messages), # type: ignore
544
+ input=self._format_messages(messages, compress_tool_results), # type: ignore
539
545
  **request_params,
540
546
  )
541
547
 
@@ -588,6 +594,7 @@ class OpenAIResponses(Model):
588
594
  tools: Optional[List[Dict[str, Any]]] = None,
589
595
  tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
590
596
  run_response: Optional[RunOutput] = None,
597
+ compress_tool_results: bool = False,
591
598
  ) -> ModelResponse:
592
599
  """
593
600
  Sends an asynchronous request to the OpenAI Responses API.
@@ -604,7 +611,7 @@ class OpenAIResponses(Model):
604
611
 
605
612
  provider_response = await self.get_async_client().responses.create(
606
613
  model=self.id,
607
- input=self._format_messages(messages), # type: ignore
614
+ input=self._format_messages(messages, compress_tool_results), # type: ignore
608
615
  **request_params,
609
616
  )
610
617
 
@@ -657,6 +664,7 @@ class OpenAIResponses(Model):
657
664
  tools: Optional[List[Dict[str, Any]]] = None,
658
665
  tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
659
666
  run_response: Optional[RunOutput] = None,
667
+ compress_tool_results: bool = False,
660
668
  ) -> Iterator[ModelResponse]:
661
669
  """
662
670
  Send a streaming request to the OpenAI Responses API.
@@ -674,7 +682,7 @@ class OpenAIResponses(Model):
674
682
 
675
683
  for chunk in self.get_client().responses.create(
676
684
  model=self.id,
677
- input=self._format_messages(messages), # type: ignore
685
+ input=self._format_messages(messages, compress_tool_results), # type: ignore
678
686
  stream=True,
679
687
  **request_params,
680
688
  ):
@@ -730,6 +738,7 @@ class OpenAIResponses(Model):
730
738
  tools: Optional[List[Dict[str, Any]]] = None,
731
739
  tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
732
740
  run_response: Optional[RunOutput] = None,
741
+ compress_tool_results: bool = False,
733
742
  ) -> AsyncIterator[ModelResponse]:
734
743
  """
735
744
  Sends an asynchronous streaming request to the OpenAI Responses API.
@@ -747,7 +756,7 @@ class OpenAIResponses(Model):
747
756
 
748
757
  async_stream = await self.get_async_client().responses.create(
749
758
  model=self.id,
750
- input=self._format_messages(messages), # type: ignore
759
+ input=self._format_messages(messages, compress_tool_results), # type: ignore
751
760
  stream=True,
752
761
  **request_params,
753
762
  )
@@ -793,7 +802,11 @@ class OpenAIResponses(Model):
793
802
  raise ModelProviderError(message=str(exc), model_name=self.name, model_id=self.id) from exc
794
803
 
795
804
  def format_function_call_results(
796
- self, messages: List[Message], function_call_results: List[Message], tool_call_ids: List[str]
805
+ self,
806
+ messages: List[Message],
807
+ function_call_results: List[Message],
808
+ tool_call_ids: List[str],
809
+ compress_tool_results: bool = False,
797
810
  ) -> None:
798
811
  """
799
812
  Handle the results of function calls.
@@ -802,6 +815,7 @@ class OpenAIResponses(Model):
802
815
  messages (List[Message]): The list of conversation messages.
803
816
  function_call_results (List[Message]): The results of the function calls.
804
817
  tool_ids (List[str]): The tool ids.
818
+ compress_tool_results (bool): Whether to compress tool results.
805
819
  """
806
820
  if len(function_call_results) > 0:
807
821
  for _fc_message_index, _fc_message in enumerate(function_call_results):
@@ -1,12 +1,14 @@
1
1
  from dataclasses import dataclass
2
2
  from os import getenv
3
- from typing import Any, Dict, Optional
3
+ from typing import Any, Dict, List, Optional, Type, Union
4
4
 
5
5
  import httpx
6
+ from pydantic import BaseModel
6
7
 
7
8
  from agno.models.anthropic import Claude as AnthropicClaude
8
9
  from agno.utils.http import get_default_async_client, get_default_sync_client
9
- from agno.utils.log import log_warning
10
+ from agno.utils.log import log_debug, log_warning
11
+ from agno.utils.models.claude import format_tools_for_model
10
12
 
11
13
  try:
12
14
  from anthropic import AnthropicVertex, AsyncAnthropicVertex
@@ -26,14 +28,23 @@ class Claude(AnthropicClaude):
26
28
  name: str = "Claude"
27
29
  provider: str = "VertexAI"
28
30
 
29
- client: Optional[AnthropicVertex] = None # type: ignore
30
- async_client: Optional[AsyncAnthropicVertex] = None # type: ignore
31
-
32
31
  # Client parameters
33
32
  region: Optional[str] = None
34
33
  project_id: Optional[str] = None
35
34
  base_url: Optional[str] = None
36
35
 
36
+ client: Optional[AnthropicVertex] = None # type: ignore
37
+ async_client: Optional[AsyncAnthropicVertex] = None # type: ignore
38
+
39
+ def __post_init__(self):
40
+ """Validate model configuration after initialization"""
41
+ # Validate thinking support immediately at model creation
42
+ if self.thinking:
43
+ self._validate_thinking_support()
44
+ # Overwrite output schema support for VertexAI Claude
45
+ self.supports_native_structured_outputs = False
46
+ self.supports_json_schema_outputs = False
47
+
37
48
  def _get_client_params(self) -> Dict[str, Any]:
38
49
  client_params: Dict[str, Any] = {}
39
50
 
@@ -94,3 +105,86 @@ class Claude(AnthropicClaude):
94
105
  _client_params["http_client"] = get_default_async_client()
95
106
  self.async_client = AsyncAnthropicVertex(**_client_params)
96
107
  return self.async_client
108
+
109
+ def get_request_params(
110
+ self,
111
+ response_format: Optional[Union[Dict, Type[BaseModel]]] = None,
112
+ tools: Optional[List[Dict[str, Any]]] = None,
113
+ ) -> Dict[str, Any]:
114
+ """
115
+ Generate keyword arguments for API requests.
116
+
117
+ Returns:
118
+ Dict[str, Any]: The keyword arguments for API requests.
119
+ """
120
+ # Validate thinking support if thinking is enabled
121
+ if self.thinking:
122
+ self._validate_thinking_support()
123
+
124
+ _request_params: Dict[str, Any] = {}
125
+ if self.max_tokens:
126
+ _request_params["max_tokens"] = self.max_tokens
127
+ if self.thinking:
128
+ _request_params["thinking"] = self.thinking
129
+ if self.temperature:
130
+ _request_params["temperature"] = self.temperature
131
+ if self.stop_sequences:
132
+ _request_params["stop_sequences"] = self.stop_sequences
133
+ if self.top_p:
134
+ _request_params["top_p"] = self.top_p
135
+ if self.top_k:
136
+ _request_params["top_k"] = self.top_k
137
+ if self.timeout:
138
+ _request_params["timeout"] = self.timeout
139
+
140
+ # Build betas list - include existing betas and add new one if needed
141
+ betas_list = list(self.betas) if self.betas else []
142
+
143
+ # Include betas if any are present
144
+ if betas_list:
145
+ _request_params["betas"] = betas_list
146
+
147
+ if self.request_params:
148
+ _request_params.update(self.request_params)
149
+
150
+ if _request_params:
151
+ log_debug(f"Calling {self.provider} with request parameters: {_request_params}", log_level=2)
152
+ return _request_params
153
+
154
+ def _prepare_request_kwargs(
155
+ self,
156
+ system_message: str,
157
+ tools: Optional[List[Dict[str, Any]]] = None,
158
+ response_format: Optional[Union[Dict, Type[BaseModel]]] = None,
159
+ ) -> Dict[str, Any]:
160
+ """
161
+ Prepare the request keyword arguments for the API call.
162
+
163
+ Args:
164
+ system_message (str): The concatenated system messages.
165
+ tools: Optional list of tools
166
+ response_format: Optional response format (Pydantic model or dict)
167
+
168
+ Returns:
169
+ Dict[str, Any]: The request keyword arguments.
170
+ """
171
+ # Pass response_format and tools to get_request_params for beta header handling
172
+ request_kwargs = self.get_request_params(response_format=response_format, tools=tools).copy()
173
+ if system_message:
174
+ if self.cache_system_prompt:
175
+ cache_control = (
176
+ {"type": "ephemeral", "ttl": "1h"}
177
+ if self.extended_cache_time is not None and self.extended_cache_time is True
178
+ else {"type": "ephemeral"}
179
+ )
180
+ request_kwargs["system"] = [{"text": system_message, "type": "text", "cache_control": cache_control}]
181
+ else:
182
+ request_kwargs["system"] = [{"text": system_message, "type": "text"}]
183
+
184
+ # Format tools (this will handle strict mode)
185
+ if tools:
186
+ request_kwargs["tools"] = format_tools_for_model(tools)
187
+
188
+ if request_kwargs:
189
+ log_debug(f"Calling {self.provider} with request parameters: {request_kwargs}", log_level=2)
190
+ return request_kwargs
@@ -33,6 +33,7 @@ async def run_agent(agent: Agent, run_input: RunAgentInput) -> AsyncIterator[Bas
33
33
  try:
34
34
  # Preparing the input for the Agent and emitting the run started event
35
35
  messages = convert_agui_messages_to_agno_messages(run_input.messages or [])
36
+
36
37
  yield RunStartedEvent(type=EventType.RUN_STARTED, thread_id=run_input.thread_id, run_id=run_id)
37
38
 
38
39
  # Look for user_id in run_input.forwarded_props
@@ -28,7 +28,7 @@ from agno.models.message import Message
28
28
  from agno.run.agent import RunContentEvent, RunEvent, RunOutputEvent, RunPausedEvent
29
29
  from agno.run.team import RunContentEvent as TeamRunContentEvent
30
30
  from agno.run.team import TeamRunEvent, TeamRunOutputEvent
31
- from agno.utils.log import log_warning
31
+ from agno.utils.log import log_debug, log_warning
32
32
  from agno.utils.message import get_text_from_message
33
33
 
34
34
 
@@ -116,23 +116,43 @@ class EventBuffer:
116
116
 
117
117
  def convert_agui_messages_to_agno_messages(messages: List[AGUIMessage]) -> List[Message]:
118
118
  """Convert AG-UI messages to Agno messages."""
119
- result = []
119
+ # First pass: collect all tool_call_ids that have results
120
+ tool_call_ids_with_results: Set[str] = set()
121
+ for msg in messages:
122
+ if msg.role == "tool" and msg.tool_call_id:
123
+ tool_call_ids_with_results.add(msg.tool_call_id)
124
+
125
+ # Second pass: convert messages
126
+ result: List[Message] = []
127
+ seen_tool_call_ids: Set[str] = set()
128
+
120
129
  for msg in messages:
121
130
  if msg.role == "tool":
131
+ # Deduplicate tool results - keep only first occurrence
132
+ if msg.tool_call_id in seen_tool_call_ids:
133
+ log_debug(f"Skipping duplicate AGUI tool result: {msg.tool_call_id}")
134
+ continue
135
+ seen_tool_call_ids.add(msg.tool_call_id)
122
136
  result.append(Message(role="tool", tool_call_id=msg.tool_call_id, content=msg.content))
137
+
123
138
  elif msg.role == "assistant":
124
139
  tool_calls = None
125
140
  if msg.tool_calls:
126
- tool_calls = [call.model_dump() for call in msg.tool_calls]
127
- result.append(
128
- Message(
129
- role="assistant",
130
- content=msg.content,
131
- tool_calls=tool_calls,
132
- )
133
- )
141
+ # Filter tool_calls to only those with results in this message sequence
142
+ filtered_calls = [call for call in msg.tool_calls if call.id in tool_call_ids_with_results]
143
+ if filtered_calls:
144
+ tool_calls = [call.model_dump() for call in filtered_calls]
145
+ result.append(Message(role="assistant", content=msg.content, tool_calls=tool_calls))
146
+
134
147
  elif msg.role == "user":
135
148
  result.append(Message(role="user", content=msg.content))
149
+
150
+ elif msg.role == "system":
151
+ pass # Skip - agent builds its own system message from configuration
152
+
153
+ else:
154
+ log_warning(f"Unknown AGUI message role: {msg.role}")
155
+
136
156
  return result
137
157
 
138
158
 
@@ -250,7 +270,25 @@ def _create_events_from_chunk(
250
270
  parent_message_id = event_buffer.get_parent_message_id_for_tool_call()
251
271
 
252
272
  if not parent_message_id:
253
- parent_message_id = current_message_id
273
+ # Create parent message for tool calls without preceding assistant message
274
+ parent_message_id = str(uuid.uuid4())
275
+
276
+ # Emit a text message to serve as the parent
277
+ text_start = TextMessageStartEvent(
278
+ type=EventType.TEXT_MESSAGE_START,
279
+ message_id=parent_message_id,
280
+ role="assistant",
281
+ )
282
+ events_to_emit.append(text_start)
283
+
284
+ text_end = TextMessageEndEvent(
285
+ type=EventType.TEXT_MESSAGE_END,
286
+ message_id=parent_message_id,
287
+ )
288
+ events_to_emit.append(text_end)
289
+
290
+ # Set this as the pending parent for subsequent tool calls in this batch
291
+ event_buffer.set_pending_tool_calls_parent_id(parent_message_id)
254
292
 
255
293
  start_event = ToolCallStartEvent(
256
294
  type=EventType.TOOL_CALL_START,
@@ -341,58 +379,60 @@ def _create_completion_events(
341
379
  end_message_event = TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=message_id)
342
380
  events_to_emit.append(end_message_event)
343
381
 
344
- # emit frontend tool calls, i.e. external_execution=True
345
- if isinstance(chunk, RunPausedEvent) and chunk.tools is not None:
346
- # First, emit an assistant message for external tool calls
347
- assistant_message_id = str(uuid.uuid4())
348
- assistant_start_event = TextMessageStartEvent(
349
- type=EventType.TEXT_MESSAGE_START,
350
- message_id=assistant_message_id,
351
- role="assistant",
352
- )
353
- events_to_emit.append(assistant_start_event)
354
-
355
- # Add any text content if present for the assistant message
356
- if chunk.content:
357
- content_event = TextMessageContentEvent(
358
- type=EventType.TEXT_MESSAGE_CONTENT,
382
+ # Emit external execution tools
383
+ if isinstance(chunk, RunPausedEvent):
384
+ external_tools = chunk.tools_awaiting_external_execution
385
+ if external_tools:
386
+ # First, emit an assistant message for external tool calls
387
+ assistant_message_id = str(uuid.uuid4())
388
+ assistant_start_event = TextMessageStartEvent(
389
+ type=EventType.TEXT_MESSAGE_START,
359
390
  message_id=assistant_message_id,
360
- delta=str(chunk.content),
391
+ role="assistant",
361
392
  )
362
- events_to_emit.append(content_event)
363
-
364
- # End the assistant message
365
- assistant_end_event = TextMessageEndEvent(
366
- type=EventType.TEXT_MESSAGE_END,
367
- message_id=assistant_message_id,
368
- )
369
- events_to_emit.append(assistant_end_event)
370
-
371
- # Now emit the tool call events with the assistant message as parent
372
- for tool in chunk.tools:
373
- if tool.tool_call_id is None or tool.tool_name is None:
374
- continue
393
+ events_to_emit.append(assistant_start_event)
394
+
395
+ # Add any text content if present for the assistant message
396
+ if chunk.content:
397
+ content_event = TextMessageContentEvent(
398
+ type=EventType.TEXT_MESSAGE_CONTENT,
399
+ message_id=assistant_message_id,
400
+ delta=str(chunk.content),
401
+ )
402
+ events_to_emit.append(content_event)
375
403
 
376
- start_event = ToolCallStartEvent(
377
- type=EventType.TOOL_CALL_START,
378
- tool_call_id=tool.tool_call_id,
379
- tool_call_name=tool.tool_name,
380
- parent_message_id=assistant_message_id, # Use the assistant message as parent
404
+ # End the assistant message
405
+ assistant_end_event = TextMessageEndEvent(
406
+ type=EventType.TEXT_MESSAGE_END,
407
+ message_id=assistant_message_id,
381
408
  )
382
- events_to_emit.append(start_event)
409
+ events_to_emit.append(assistant_end_event)
410
+
411
+ # Emit tool call events for external execution
412
+ for tool in external_tools:
413
+ if tool.tool_call_id is None or tool.tool_name is None:
414
+ continue
415
+
416
+ start_event = ToolCallStartEvent(
417
+ type=EventType.TOOL_CALL_START,
418
+ tool_call_id=tool.tool_call_id,
419
+ tool_call_name=tool.tool_name,
420
+ parent_message_id=assistant_message_id, # Use the assistant message as parent
421
+ )
422
+ events_to_emit.append(start_event)
383
423
 
384
- args_event = ToolCallArgsEvent(
385
- type=EventType.TOOL_CALL_ARGS,
386
- tool_call_id=tool.tool_call_id,
387
- delta=json.dumps(tool.tool_args),
388
- )
389
- events_to_emit.append(args_event)
424
+ args_event = ToolCallArgsEvent(
425
+ type=EventType.TOOL_CALL_ARGS,
426
+ tool_call_id=tool.tool_call_id,
427
+ delta=json.dumps(tool.tool_args),
428
+ )
429
+ events_to_emit.append(args_event)
390
430
 
391
- end_event = ToolCallEndEvent(
392
- type=EventType.TOOL_CALL_END,
393
- tool_call_id=tool.tool_call_id,
394
- )
395
- events_to_emit.append(end_event)
431
+ end_event = ToolCallEndEvent(
432
+ type=EventType.TOOL_CALL_END,
433
+ tool_call_id=tool.tool_call_id,
434
+ )
435
+ events_to_emit.append(end_event)
396
436
 
397
437
  run_finished_event = RunFinishedEvent(type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id)
398
438
  events_to_emit.append(run_finished_event)
agno/os/router.py CHANGED
@@ -139,6 +139,22 @@ async def _get_request_kwargs(request: Request, endpoint_func: Callable) -> Dict
139
139
  kwargs.pop("knowledge_filters")
140
140
  log_warning(f"Invalid FilterExpr in knowledge_filters: {e}")
141
141
 
142
+ # Handle output_schema - convert JSON schema to dynamic Pydantic model
143
+ if output_schema := kwargs.get("output_schema"):
144
+ try:
145
+ if isinstance(output_schema, str):
146
+ from agno.os.utils import json_schema_to_pydantic_model
147
+
148
+ schema_dict = json.loads(output_schema)
149
+ dynamic_model = json_schema_to_pydantic_model(schema_dict)
150
+ kwargs["output_schema"] = dynamic_model
151
+ except json.JSONDecodeError:
152
+ kwargs.pop("output_schema")
153
+ log_warning(f"Invalid output_schema JSON: {output_schema}")
154
+ except Exception as e:
155
+ kwargs.pop("output_schema")
156
+ log_warning(f"Failed to create output_schema model: {e}")
157
+
142
158
  # Parse boolean and null values
143
159
  for key, value in kwargs.items():
144
160
  if isinstance(value, str) and value.lower() in ["true", "false"]:
@@ -1794,7 +1810,6 @@ def get_base_router(
1794
1810
  raise HTTPException(status_code=404, detail="Database not found")
1795
1811
 
1796
1812
  if target_version:
1797
-
1798
1813
  # Use the session table as proxy for the database schema version
1799
1814
  if isinstance(db, AsyncBaseDb):
1800
1815
  current_version = await db.get_latest_schema_version(db.session_table_name)