fast-agent-mcp 0.2.35__py3-none-any.whl → 0.2.37__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 fast-agent-mcp might be problematic. Click here for more details.

Files changed (73) hide show
  1. {fast_agent_mcp-0.2.35.dist-info → fast_agent_mcp-0.2.37.dist-info}/METADATA +15 -12
  2. {fast_agent_mcp-0.2.35.dist-info → fast_agent_mcp-0.2.37.dist-info}/RECORD +55 -56
  3. {fast_agent_mcp-0.2.35.dist-info → fast_agent_mcp-0.2.37.dist-info}/licenses/LICENSE +1 -1
  4. mcp_agent/agents/base_agent.py +2 -2
  5. mcp_agent/agents/workflow/router_agent.py +1 -1
  6. mcp_agent/cli/commands/quickstart.py +59 -5
  7. mcp_agent/config.py +10 -0
  8. mcp_agent/context.py +1 -4
  9. mcp_agent/core/agent_types.py +7 -6
  10. mcp_agent/core/direct_decorators.py +14 -0
  11. mcp_agent/core/direct_factory.py +1 -0
  12. mcp_agent/core/enhanced_prompt.py +73 -13
  13. mcp_agent/core/fastagent.py +23 -2
  14. mcp_agent/core/interactive_prompt.py +118 -8
  15. mcp_agent/human_input/elicitation_form.py +723 -0
  16. mcp_agent/human_input/elicitation_forms.py +59 -0
  17. mcp_agent/human_input/elicitation_handler.py +88 -0
  18. mcp_agent/human_input/elicitation_state.py +34 -0
  19. mcp_agent/llm/augmented_llm.py +31 -0
  20. mcp_agent/llm/providers/augmented_llm_anthropic.py +11 -23
  21. mcp_agent/llm/providers/augmented_llm_azure.py +4 -4
  22. mcp_agent/llm/providers/augmented_llm_google_native.py +4 -2
  23. mcp_agent/llm/providers/augmented_llm_openai.py +195 -12
  24. mcp_agent/llm/providers/multipart_converter_openai.py +4 -3
  25. mcp_agent/mcp/elicitation_factory.py +84 -0
  26. mcp_agent/mcp/elicitation_handlers.py +155 -0
  27. mcp_agent/mcp/helpers/content_helpers.py +27 -0
  28. mcp_agent/mcp/helpers/server_config_helpers.py +10 -8
  29. mcp_agent/mcp/interfaces.py +1 -1
  30. mcp_agent/mcp/mcp_agent_client_session.py +44 -1
  31. mcp_agent/mcp/mcp_aggregator.py +56 -11
  32. mcp_agent/mcp/mcp_connection_manager.py +30 -18
  33. mcp_agent/mcp_server/agent_server.py +2 -0
  34. mcp_agent/mcp_server_registry.py +16 -8
  35. mcp_agent/resources/examples/data-analysis/analysis.py +1 -2
  36. mcp_agent/resources/examples/mcp/elicitations/README.md +157 -0
  37. mcp_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
  38. mcp_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +232 -0
  39. mcp_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
  40. mcp_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
  41. mcp_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
  42. mcp_agent/resources/examples/mcp/elicitations/forms_demo.py +111 -0
  43. mcp_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
  44. mcp_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
  45. mcp_agent/resources/examples/{prompting/agent.py → mcp/elicitations/tool_call.py} +4 -5
  46. mcp_agent/resources/examples/mcp/state-transfer/agent_two.py +1 -1
  47. mcp_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +1 -1
  48. mcp_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +1 -0
  49. mcp_agent/resources/examples/workflows/evaluator.py +1 -1
  50. mcp_agent/resources/examples/workflows/graded_report.md +89 -0
  51. mcp_agent/resources/examples/workflows/orchestrator.py +7 -9
  52. mcp_agent/resources/examples/workflows/parallel.py +0 -2
  53. mcp_agent/resources/examples/workflows/short_story.md +13 -0
  54. mcp_agent/resources/examples/in_dev/agent_build.py +0 -84
  55. mcp_agent/resources/examples/in_dev/css-LICENSE.txt +0 -21
  56. mcp_agent/resources/examples/in_dev/slides.py +0 -110
  57. mcp_agent/resources/examples/internal/agent.py +0 -20
  58. mcp_agent/resources/examples/internal/fastagent.config.yaml +0 -66
  59. mcp_agent/resources/examples/internal/history_transfer.py +0 -35
  60. mcp_agent/resources/examples/internal/job.py +0 -84
  61. mcp_agent/resources/examples/internal/prompt_category.py +0 -21
  62. mcp_agent/resources/examples/internal/prompt_sizing.py +0 -51
  63. mcp_agent/resources/examples/internal/simple.txt +0 -2
  64. mcp_agent/resources/examples/internal/sizer.py +0 -20
  65. mcp_agent/resources/examples/internal/social.py +0 -67
  66. mcp_agent/resources/examples/prompting/__init__.py +0 -3
  67. mcp_agent/resources/examples/prompting/delimited_prompt.txt +0 -14
  68. mcp_agent/resources/examples/prompting/fastagent.config.yaml +0 -43
  69. mcp_agent/resources/examples/prompting/image_server.py +0 -52
  70. mcp_agent/resources/examples/prompting/prompt1.txt +0 -6
  71. mcp_agent/resources/examples/prompting/work_with_image.py +0 -19
  72. {fast_agent_mcp-0.2.35.dist-info → fast_agent_mcp-0.2.37.dist-info}/WHEEL +0 -0
  73. {fast_agent_mcp-0.2.35.dist-info → fast_agent_mcp-0.2.37.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,59 @@
1
+ """Shared styling configuration for MCP elicitation forms."""
2
+
3
+ from prompt_toolkit.styles import Style
4
+
5
+ # Define consistent elicitation style - inspired by usage display and interactive prompt
6
+ ELICITATION_STYLE = Style.from_dict(
7
+ {
8
+ # Dialog structure - use ansidefault for true black, remove problematic shadow
9
+ "dialog": "bg:ansidefault", # True black dialog using ansidefault
10
+ "dialog.body": "bg:ansidefault fg:ansiwhite", # True black dialog body
11
+ "dialog shadow": "bg:ansidefault", # Set shadow background to match application
12
+ "dialog.border": "bg:ansidefault", # True black border background
13
+ # Set application background to true black
14
+ "application": "bg:ansidefault", # True black application background
15
+ # Title styling with better contrast
16
+ "title": "fg:ansibrightmagenta bold", # Bright magenta text
17
+ # Buttons - only define focused state to preserve focus highlighting
18
+ "button.focused": "bg:ansibrightgreen fg:ansiblack bold", # Bright green with black text for contrast
19
+ "button.arrow": "fg:ansiwhite bold", # White arrows for visibility
20
+ # Form elements with consistent green/yellow theme
21
+ # Checkboxes - green when checked, yellow when focused
22
+ "checkbox": "fg:ansidefault", # Default color unchecked checkbox (dimmer)
23
+ "checkbox-checked": "fg:ansibrightgreen bold", # Green when checked (matches buttons)
24
+ "checkbox-selected": "bg:ansidefault fg:ansibrightyellow bold", # Yellow when focused
25
+ # Radio list styling - consistent with checkbox colors
26
+ "radio-list": "bg:ansidefault", # True black background for radio list
27
+ "radio": "fg:ansidefault", # Default color for unselected items (dimmer)
28
+ "radio-selected": "bg:ansidefault fg:ansibrightyellow bold", # Yellow when focused
29
+ "radio-checked": "fg:ansibrightgreen bold", # Green when selected (matches buttons)
30
+ # Text input areas - use ansidefault for non-focused (dimmer effect)
31
+ "input-field": "fg:ansidefault bold", # Default color (inactive) - should be dimmer than white
32
+ "input-field.focused": "fg:ansibrightyellow bold", # Bright yellow (active)
33
+ "input-field.error": "fg:ansired bold", # Red text (validation error)
34
+ # Frame styling with ANSI colors - make borders visible
35
+ "frame.border": "fg:ansibrightblack", # Bright black borders for subtlety
36
+ "frame.label": "fg:ansigray", # Gray frame labels (less prominent)
37
+ # Labels and text - use white for good visibility
38
+ "label": "fg:ansiwhite", # White labels for good readability
39
+ "message": "fg:ansibrightcyan", # Bright cyan messages (no bold)
40
+ # Agent and server names - make them match
41
+ "agent-name": "fg:ansibrightblue bold",
42
+ "server-name": "fg:ansibrightblue bold", # Same color as agent
43
+ # Validation errors - better contrast
44
+ "validation-toolbar": "bg:ansibrightred fg:ansiwhite bold",
45
+ "validation-toolbar.text": "bg:ansibrightred fg:ansiwhite",
46
+ "validation.border": "fg:ansibrightred",
47
+ "validation-error": "fg:ansibrightred bold", # For status line errors
48
+ # Separator styling
49
+ "separator": "fg:ansibrightblue bold",
50
+ # Completion menu - exactly matching enhanced_prompt.py
51
+ "completion-menu.completion": "bg:ansiblack fg:ansigreen",
52
+ "completion-menu.completion.current": "bg:ansiblack fg:ansigreen bold",
53
+ "completion-menu.meta.completion": "bg:ansiblack fg:ansiblue",
54
+ "completion-menu.meta.completion.current": "bg:ansibrightblack fg:ansiblue",
55
+ # Toolbar - matching enhanced_prompt.py exactly
56
+ "bottom-toolbar": "fg:ansiblack bg:ansigray",
57
+ "bottom-toolbar.text": "fg:ansiblack bg:ansigray",
58
+ }
59
+ )
@@ -0,0 +1,88 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from mcp_agent.human_input.elicitation_form import (
4
+ show_simple_elicitation_form,
5
+ )
6
+ from mcp_agent.human_input.elicitation_forms import (
7
+ ELICITATION_STYLE,
8
+ )
9
+ from mcp_agent.human_input.elicitation_state import elicitation_state
10
+ from mcp_agent.human_input.types import (
11
+ HumanInputRequest,
12
+ HumanInputResponse,
13
+ )
14
+ from mcp_agent.progress_display import progress_display
15
+
16
+
17
+ async def elicitation_input_callback(
18
+ request: HumanInputRequest,
19
+ agent_name: str | None = None,
20
+ server_name: str | None = None,
21
+ server_info: dict[str, Any] | None = None,
22
+ ) -> HumanInputResponse:
23
+ """Request input from a human user for MCP server elicitation requests."""
24
+
25
+ # Extract effective names
26
+ effective_agent_name = agent_name or (
27
+ request.metadata.get("agent_name", "Unknown Agent") if request.metadata else "Unknown Agent"
28
+ )
29
+ effective_server_name = server_name or "Unknown Server"
30
+
31
+ # Check if elicitation is disabled for this server
32
+ if elicitation_state.is_disabled(effective_server_name):
33
+ return HumanInputResponse(
34
+ request_id=request.request_id,
35
+ response="__CANCELLED__",
36
+ metadata={"auto_cancelled": True, "reason": "Server elicitation disabled by user"},
37
+ )
38
+
39
+ # Get the elicitation schema from metadata
40
+ schema: Optional[Dict[str, Any]] = None
41
+ if request.metadata and "requested_schema" in request.metadata:
42
+ schema = request.metadata["requested_schema"]
43
+
44
+ # Use the context manager to pause the progress display while getting input
45
+ with progress_display.paused():
46
+ try:
47
+ if schema:
48
+ form_action, form_data = await show_simple_elicitation_form(
49
+ schema=schema,
50
+ message=request.prompt,
51
+ agent_name=effective_agent_name,
52
+ server_name=effective_server_name,
53
+ )
54
+
55
+ if form_action == "accept" and form_data is not None:
56
+ # Convert form data to JSON string
57
+ import json
58
+
59
+ response = json.dumps(form_data)
60
+ elif form_action == "decline":
61
+ response = "__DECLINED__"
62
+ elif form_action == "disable":
63
+ response = "__DISABLE_SERVER__"
64
+ else: # cancel
65
+ response = "__CANCELLED__"
66
+ else:
67
+ # No schema, fall back to text input using prompt_toolkit only
68
+ from prompt_toolkit.shortcuts import input_dialog
69
+
70
+ response = await input_dialog(
71
+ title="Input Requested",
72
+ text=f"Agent: {effective_agent_name}\nServer: {effective_server_name}\n\n{request.prompt}",
73
+ style=ELICITATION_STYLE,
74
+ ).run_async()
75
+
76
+ if response is None:
77
+ response = "__CANCELLED__"
78
+
79
+ except KeyboardInterrupt:
80
+ response = "__CANCELLED__"
81
+ except EOFError:
82
+ response = "__CANCELLED__"
83
+
84
+ return HumanInputResponse(
85
+ request_id=request.request_id,
86
+ response=response.strip() if isinstance(response, str) else response,
87
+ metadata={"has_schema": schema is not None},
88
+ )
@@ -0,0 +1,34 @@
1
+ """Simple state management for elicitation Cancel All functionality."""
2
+
3
+ from typing import Set
4
+
5
+
6
+ class ElicitationState:
7
+ """Manages global state for elicitation requests, including disabled servers."""
8
+
9
+ def __init__(self):
10
+ self.disabled_servers: Set[str] = set()
11
+
12
+ def disable_server(self, server_name: str) -> None:
13
+ """Disable elicitation requests for a specific server."""
14
+ self.disabled_servers.add(server_name)
15
+
16
+ def is_disabled(self, server_name: str) -> bool:
17
+ """Check if elicitation is disabled for a server."""
18
+ return server_name in self.disabled_servers
19
+
20
+ def enable_server(self, server_name: str) -> None:
21
+ """Re-enable elicitation requests for a specific server."""
22
+ self.disabled_servers.discard(server_name)
23
+
24
+ def clear_all(self) -> None:
25
+ """Clear all disabled servers."""
26
+ self.disabled_servers.clear()
27
+
28
+ def get_disabled_servers(self) -> Set[str]:
29
+ """Get a copy of all disabled servers."""
30
+ return self.disabled_servers.copy()
31
+
32
+
33
+ # Global instance for session-scoped Cancel All functionality
34
+ elicitation_state = ElicitationState()
@@ -554,6 +554,37 @@ class AugmentedLLM(ContextDependent, AugmentedLLMProtocol, Generic[MessageParamT
554
554
  }
555
555
  self.logger.debug("Chat in progress", data=data)
556
556
 
557
+ def _update_streaming_progress(self, content: str, model: str, estimated_tokens: int) -> int:
558
+ """Update streaming progress with token estimation and formatting.
559
+
560
+ Args:
561
+ content: The text content from the streaming event
562
+ model: The model name
563
+ estimated_tokens: Current token count to update
564
+
565
+ Returns:
566
+ Updated estimated token count
567
+ """
568
+ # Rough estimate: 1 token per 4 characters (OpenAI's typical ratio)
569
+ text_length = len(content)
570
+ additional_tokens = max(1, text_length // 4)
571
+ new_total = estimated_tokens + additional_tokens
572
+
573
+ # Format token count for display
574
+ token_str = str(new_total).rjust(5)
575
+
576
+ # Emit progress event
577
+ data = {
578
+ "progress_action": ProgressAction.STREAMING,
579
+ "model": model,
580
+ "agent_name": self.name,
581
+ "chat_turn": self.chat_turn(),
582
+ "details": token_str.strip(), # Token count goes in details for STREAMING action
583
+ }
584
+ self.logger.info("Streaming progress", data=data)
585
+
586
+ return new_total
587
+
557
588
  def _log_chat_finished(self, model: Optional[str] = None) -> None:
558
589
  """Log a chat finished event"""
559
590
  data = {
@@ -111,14 +111,8 @@ class AnthropicAugmentedLLM(AugmentedLLM[MessageParam, Message]):
111
111
  and hasattr(event, "delta")
112
112
  and event.delta.type == "text_delta"
113
113
  ):
114
- # Rough estimate: 1 token per 4 characters (OpenAI's typical ratio)
115
- text_length = len(event.delta.text)
116
- estimated_tokens += max(1, text_length // 4)
117
-
118
- # Update progress on every token for real-time display
119
- token_str = str(estimated_tokens).rjust(5)
120
- # print(f"DEBUG: Streaming tokens: {token_str}")
121
- self._emit_streaming_progress(model, token_str)
114
+ # Use base class method for token estimation and progress emission
115
+ estimated_tokens = self._update_streaming_progress(event.delta.text, model, estimated_tokens)
122
116
 
123
117
  # Also check for final message_delta events with actual usage info
124
118
  elif (
@@ -127,9 +121,16 @@ class AnthropicAugmentedLLM(AugmentedLLM[MessageParam, Message]):
127
121
  and event.usage.output_tokens
128
122
  ):
129
123
  actual_tokens = event.usage.output_tokens
124
+ # Emit final progress with actual token count
130
125
  token_str = str(actual_tokens).rjust(5)
131
- # print(f"DEBUG: Final actual tokens: {token_str}")
132
- self._emit_streaming_progress(model, token_str)
126
+ data = {
127
+ "progress_action": ProgressAction.STREAMING,
128
+ "model": model,
129
+ "agent_name": self.name,
130
+ "chat_turn": self.chat_turn(),
131
+ "details": token_str.strip(),
132
+ }
133
+ self.logger.info("Streaming progress", data=data)
133
134
 
134
135
  # Get the final message with complete usage data
135
136
  message = await stream.get_final_message()
@@ -142,19 +143,6 @@ class AnthropicAugmentedLLM(AugmentedLLM[MessageParam, Message]):
142
143
 
143
144
  return message
144
145
 
145
- def _emit_streaming_progress(self, model: str, token_str: str) -> None:
146
- """Emit a streaming progress event that goes directly to progress display."""
147
- data = {
148
- "progress_action": ProgressAction.STREAMING,
149
- "model": model,
150
- "agent_name": self.name,
151
- "chat_turn": self.chat_turn(),
152
- "details": token_str.strip(), # Token count goes in details for STREAMING action
153
- }
154
- # print(f"DEBUG: Emitting streaming progress event with data: {data}")
155
- # Use a special logger level or namespace to avoid polluting regular logs
156
- self.logger.info("Streaming progress", data=data)
157
-
158
146
  async def _anthropic_completion(
159
147
  self,
160
148
  message_param,
@@ -1,4 +1,4 @@
1
- from openai import AuthenticationError, AzureOpenAI, OpenAI
1
+ from openai import AsyncAzureOpenAI, AsyncOpenAI, AuthenticationError
2
2
 
3
3
  from mcp_agent.core.exceptions import ProviderKeyError
4
4
  from mcp_agent.llm.provider_types import Provider
@@ -93,7 +93,7 @@ class AzureOpenAIAugmentedLLM(OpenAIAugmentedLLM):
93
93
  if not self.resource_name and self.base_url:
94
94
  self.resource_name = _extract_resource_name(self.base_url)
95
95
 
96
- def _openai_client(self) -> OpenAI:
96
+ def _openai_client(self) -> AsyncOpenAI:
97
97
  """
98
98
  Returns an AzureOpenAI client, handling both API Key and DefaultAzureCredential.
99
99
  """
@@ -104,7 +104,7 @@ class AzureOpenAIAugmentedLLM(OpenAIAugmentedLLM):
104
104
  "Missing Azure endpoint",
105
105
  "azure_endpoint (base_url) is None at client creation time.",
106
106
  )
107
- return AzureOpenAI(
107
+ return AsyncAzureOpenAI(
108
108
  azure_ad_token_provider=self.get_azure_token,
109
109
  azure_endpoint=self.base_url,
110
110
  api_version=self.api_version,
@@ -116,7 +116,7 @@ class AzureOpenAIAugmentedLLM(OpenAIAugmentedLLM):
116
116
  "Missing Azure endpoint",
117
117
  "azure_endpoint (base_url) is None at client creation time.",
118
118
  )
119
- return AzureOpenAI(
119
+ return AsyncAzureOpenAI(
120
120
  api_key=self.api_key,
121
121
  azure_endpoint=self.base_url,
122
122
  api_version=self.api_version,
@@ -242,8 +242,10 @@ class GoogleNativeAugmentedLLM(AugmentedLLM[types.Content, types.Content]):
242
242
  self.history.get(include_completion_history=True)
243
243
  )
244
244
  else:
245
- # If not using history, start with an empty list
246
- conversation_history = []
245
+ # If not using history, convert the last message to google.genai format
246
+ conversation_history = self._converter.convert_to_google_content(
247
+ self.history.get(include_completion_history=True)[-1:]
248
+ )
247
249
 
248
250
  self.logger.debug(f"Google completion requested with messages: {conversation_history}")
249
251
  self._log_chat_progress(
@@ -8,7 +8,8 @@ from mcp.types import (
8
8
  ImageContent,
9
9
  TextContent,
10
10
  )
11
- from openai import AuthenticationError, OpenAI
11
+ from openai import AsyncOpenAI, AuthenticationError
12
+ from openai.lib.streaming.chat import ChatCompletionStreamState
12
13
 
13
14
  # from openai.types.beta.chat import
14
15
  from openai.types.chat import (
@@ -22,6 +23,7 @@ from rich.text import Text
22
23
 
23
24
  from mcp_agent.core.exceptions import ProviderKeyError
24
25
  from mcp_agent.core.prompt import Prompt
26
+ from mcp_agent.event_progress import ProgressAction
25
27
  from mcp_agent.llm.augmented_llm import (
26
28
  AugmentedLLM,
27
29
  RequestParams,
@@ -103,9 +105,9 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
103
105
  def _base_url(self) -> str:
104
106
  return self.context.config.openai.base_url if self.context.config.openai else None
105
107
 
106
- def _openai_client(self) -> OpenAI:
108
+ def _openai_client(self) -> AsyncOpenAI:
107
109
  try:
108
- return OpenAI(api_key=self._api_key(), base_url=self._base_url())
110
+ return AsyncOpenAI(api_key=self._api_key(), base_url=self._base_url())
109
111
  except AuthenticationError as e:
110
112
  raise ProviderKeyError(
111
113
  "Invalid OpenAI API key",
@@ -113,6 +115,182 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
113
115
  "Please check that your API key is valid and not expired.",
114
116
  ) from e
115
117
 
118
+ async def _process_stream(self, stream, model: str):
119
+ """Process the streaming response and display real-time token usage."""
120
+ # Track estimated output tokens by counting text chunks
121
+ estimated_tokens = 0
122
+
123
+ # For non-OpenAI providers (like Ollama), ChatCompletionStreamState might not work correctly
124
+ # Fall back to manual accumulation if needed
125
+ # TODO -- consider this and whether to subclass instead
126
+ if self.provider in [Provider.GENERIC, Provider.OPENROUTER, Provider.GOOGLE_OAI]:
127
+ return await self._process_stream_manual(stream, model)
128
+
129
+ # Use ChatCompletionStreamState helper for accumulation (OpenAI only)
130
+ state = ChatCompletionStreamState()
131
+
132
+ # Process the stream chunks
133
+ async for chunk in stream:
134
+ # Handle chunk accumulation
135
+ state.handle_chunk(chunk)
136
+
137
+ # Count tokens in real-time from content deltas
138
+ if chunk.choices and chunk.choices[0].delta.content:
139
+ content = chunk.choices[0].delta.content
140
+ # Use base class method for token estimation and progress emission
141
+ estimated_tokens = self._update_streaming_progress(content, model, estimated_tokens)
142
+
143
+ # Get the final completion with usage data
144
+ final_completion = state.get_final_completion()
145
+
146
+ # Log final usage information
147
+ if hasattr(final_completion, "usage") and final_completion.usage:
148
+ actual_tokens = final_completion.usage.completion_tokens
149
+ # Emit final progress with actual token count
150
+ token_str = str(actual_tokens).rjust(5)
151
+ data = {
152
+ "progress_action": ProgressAction.STREAMING,
153
+ "model": model,
154
+ "agent_name": self.name,
155
+ "chat_turn": self.chat_turn(),
156
+ "details": token_str.strip(),
157
+ }
158
+ self.logger.info("Streaming progress", data=data)
159
+
160
+ self.logger.info(
161
+ f"Streaming complete - Model: {model}, Input tokens: {final_completion.usage.prompt_tokens}, Output tokens: {final_completion.usage.completion_tokens}"
162
+ )
163
+
164
+ return final_completion
165
+
166
+ # TODO - as per other comment this needs to go in another class. There are a number of "special" cases dealt with
167
+ # here to deal with OpenRouter idiosyncrasies between e.g. Anthropic and Gemini models.
168
+ async def _process_stream_manual(self, stream, model: str):
169
+ """Manual stream processing for providers like Ollama that may not work with ChatCompletionStreamState."""
170
+ from openai.types.chat import ChatCompletionMessageToolCall
171
+ from openai.types.chat.chat_completion_message_tool_call import Function
172
+
173
+ # Track estimated output tokens by counting text chunks
174
+ estimated_tokens = 0
175
+
176
+ # Manual accumulation of response data
177
+ accumulated_content = ""
178
+ role = "assistant"
179
+ tool_calls_map = {} # Use a map to accumulate tool calls by index
180
+ function_call = None
181
+ finish_reason = None
182
+ usage_data = None
183
+
184
+ # Process the stream chunks manually
185
+ async for chunk in stream:
186
+ # Count tokens in real-time from content deltas
187
+ if chunk.choices and chunk.choices[0].delta.content:
188
+ content = chunk.choices[0].delta.content
189
+ accumulated_content += content
190
+ # Use base class method for token estimation and progress emission
191
+ estimated_tokens = self._update_streaming_progress(content, model, estimated_tokens)
192
+
193
+ # Extract other fields from the chunk
194
+ if chunk.choices:
195
+ choice = chunk.choices[0]
196
+ if choice.delta.role:
197
+ role = choice.delta.role
198
+ if choice.delta.tool_calls:
199
+ # Accumulate tool call deltas
200
+ for delta_tool_call in choice.delta.tool_calls:
201
+ if delta_tool_call.index is not None:
202
+ if delta_tool_call.index not in tool_calls_map:
203
+ tool_calls_map[delta_tool_call.index] = {
204
+ "id": delta_tool_call.id,
205
+ "type": delta_tool_call.type or "function",
206
+ "function": {
207
+ "name": delta_tool_call.function.name
208
+ if delta_tool_call.function
209
+ else None,
210
+ "arguments": "",
211
+ },
212
+ }
213
+
214
+ # Always update if we have new data (needed for OpenRouter Gemini)
215
+ if delta_tool_call.id:
216
+ tool_calls_map[delta_tool_call.index]["id"] = delta_tool_call.id
217
+ if delta_tool_call.function:
218
+ if delta_tool_call.function.name:
219
+ tool_calls_map[delta_tool_call.index]["function"]["name"] = (
220
+ delta_tool_call.function.name
221
+ )
222
+ # Handle arguments - they might come as None, empty string, or actual content
223
+ if delta_tool_call.function.arguments is not None:
224
+ tool_calls_map[delta_tool_call.index]["function"][
225
+ "arguments"
226
+ ] += delta_tool_call.function.arguments
227
+
228
+ if choice.delta.function_call:
229
+ function_call = choice.delta.function_call
230
+ if choice.finish_reason:
231
+ finish_reason = choice.finish_reason
232
+
233
+ # Extract usage data if available
234
+ if hasattr(chunk, "usage") and chunk.usage:
235
+ usage_data = chunk.usage
236
+
237
+ # Convert accumulated tool calls to proper format.
238
+ tool_calls = None
239
+ if tool_calls_map:
240
+ tool_calls = []
241
+ for idx in sorted(tool_calls_map.keys()):
242
+ tool_call_data = tool_calls_map[idx]
243
+ # Only add tool calls that have valid data
244
+ if tool_call_data["id"] and tool_call_data["function"]["name"]:
245
+ tool_calls.append(
246
+ ChatCompletionMessageToolCall(
247
+ id=tool_call_data["id"],
248
+ type=tool_call_data["type"],
249
+ function=Function(
250
+ name=tool_call_data["function"]["name"],
251
+ arguments=tool_call_data["function"]["arguments"],
252
+ ),
253
+ )
254
+ )
255
+
256
+ # Create a ChatCompletionMessage manually
257
+ message = ChatCompletionMessage(
258
+ content=accumulated_content,
259
+ role=role,
260
+ tool_calls=tool_calls if tool_calls else None,
261
+ function_call=function_call,
262
+ refusal=None,
263
+ annotations=None,
264
+ audio=None,
265
+ )
266
+
267
+ from types import SimpleNamespace
268
+
269
+ final_completion = SimpleNamespace()
270
+ final_completion.choices = [SimpleNamespace()]
271
+ final_completion.choices[0].message = message
272
+ final_completion.choices[0].finish_reason = finish_reason
273
+ final_completion.usage = usage_data
274
+
275
+ # Log final usage information
276
+ if usage_data:
277
+ actual_tokens = getattr(usage_data, "completion_tokens", estimated_tokens)
278
+ token_str = str(actual_tokens).rjust(5)
279
+ data = {
280
+ "progress_action": ProgressAction.STREAMING,
281
+ "model": model,
282
+ "agent_name": self.name,
283
+ "chat_turn": self.chat_turn(),
284
+ "details": token_str.strip(),
285
+ }
286
+ self.logger.info("Streaming progress", data=data)
287
+
288
+ self.logger.info(
289
+ f"Streaming complete - Model: {model}, Input tokens: {getattr(usage_data, 'prompt_tokens', 0)}, Output tokens: {actual_tokens}"
290
+ )
291
+
292
+ return final_completion
293
+
116
294
  async def _openai_completion(
117
295
  self,
118
296
  message: OpenAIMessage,
@@ -151,7 +329,10 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
151
329
  ]
152
330
 
153
331
  if not available_tools:
154
- available_tools = None # deepseek does not allow empty array
332
+ if self.provider == Provider.DEEPSEEK:
333
+ available_tools = None # deepseek does not allow empty array
334
+ else:
335
+ available_tools = []
155
336
 
156
337
  # we do NOT send "stop sequences" as this causes errors with mutlimodal processing
157
338
  for i in range(request_params.max_iterations):
@@ -160,11 +341,10 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
160
341
 
161
342
  self._log_chat_progress(self.chat_turn(), model=self.default_request_params.model)
162
343
 
163
- executor_result = await self.executor.execute(
164
- self._openai_client().chat.completions.create, **arguments
165
- )
166
-
167
- response = executor_result[0]
344
+ # Use basic streaming API
345
+ stream = await self._openai_client().chat.completions.create(**arguments)
346
+ # Process the stream
347
+ response = await self._process_stream(stream, self.default_request_params.model)
168
348
 
169
349
  # Track usage if response is valid and has usage data
170
350
  if (
@@ -204,10 +384,11 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
204
384
  if message.content:
205
385
  responses.append(TextContent(type="text", text=message.content))
206
386
 
207
- converted_message = self.convert_message_to_message_param(message)
208
- messages.append(converted_message)
387
+ # ParsedChatCompletionMessage is compatible with ChatCompletionMessage
388
+ # since it inherits from it, so we can use it directly
389
+ messages.append(message)
209
390
 
210
- message_text = converted_message.content
391
+ message_text = message.content
211
392
  if choice.finish_reason in ["tool_calls", "function_call"] and message.tool_calls:
212
393
  if message_text:
213
394
  await self.show_assistant_message(
@@ -347,6 +528,8 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
347
528
  "model": self.default_request_params.model,
348
529
  "messages": messages,
349
530
  "tools": tools,
531
+ "stream": True, # Enable basic streaming
532
+ "stream_options": {"include_usage": True}, # Required for usage data in streaming
350
533
  }
351
534
 
352
535
  if self._reasoning:
@@ -360,7 +360,7 @@ class OpenAIConverter:
360
360
  return {
361
361
  "role": "tool",
362
362
  "tool_call_id": tool_call_id,
363
- "content": "[No content in tool result]",
363
+ "content": "[Tool completed successfully]",
364
364
  }
365
365
 
366
366
  # Separate text and non-text content
@@ -387,8 +387,9 @@ class OpenAIConverter:
387
387
  converted.get("content", "")
388
388
  )
389
389
 
390
- if not tool_message_content:
391
- tool_message_content = "[Tool returned non-text content]"
390
+ # Ensure we always have non-empty content for compatibility
391
+ if not tool_message_content or tool_message_content.strip() == "":
392
+ tool_message_content = "[Tool completed successfully]"
392
393
 
393
394
  # Create the tool message with just the text
394
395
  tool_message = {