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.
- {fast_agent_mcp-0.2.35.dist-info → fast_agent_mcp-0.2.37.dist-info}/METADATA +15 -12
- {fast_agent_mcp-0.2.35.dist-info → fast_agent_mcp-0.2.37.dist-info}/RECORD +55 -56
- {fast_agent_mcp-0.2.35.dist-info → fast_agent_mcp-0.2.37.dist-info}/licenses/LICENSE +1 -1
- mcp_agent/agents/base_agent.py +2 -2
- mcp_agent/agents/workflow/router_agent.py +1 -1
- mcp_agent/cli/commands/quickstart.py +59 -5
- mcp_agent/config.py +10 -0
- mcp_agent/context.py +1 -4
- mcp_agent/core/agent_types.py +7 -6
- mcp_agent/core/direct_decorators.py +14 -0
- mcp_agent/core/direct_factory.py +1 -0
- mcp_agent/core/enhanced_prompt.py +73 -13
- mcp_agent/core/fastagent.py +23 -2
- mcp_agent/core/interactive_prompt.py +118 -8
- mcp_agent/human_input/elicitation_form.py +723 -0
- mcp_agent/human_input/elicitation_forms.py +59 -0
- mcp_agent/human_input/elicitation_handler.py +88 -0
- mcp_agent/human_input/elicitation_state.py +34 -0
- mcp_agent/llm/augmented_llm.py +31 -0
- mcp_agent/llm/providers/augmented_llm_anthropic.py +11 -23
- mcp_agent/llm/providers/augmented_llm_azure.py +4 -4
- mcp_agent/llm/providers/augmented_llm_google_native.py +4 -2
- mcp_agent/llm/providers/augmented_llm_openai.py +195 -12
- mcp_agent/llm/providers/multipart_converter_openai.py +4 -3
- mcp_agent/mcp/elicitation_factory.py +84 -0
- mcp_agent/mcp/elicitation_handlers.py +155 -0
- mcp_agent/mcp/helpers/content_helpers.py +27 -0
- mcp_agent/mcp/helpers/server_config_helpers.py +10 -8
- mcp_agent/mcp/interfaces.py +1 -1
- mcp_agent/mcp/mcp_agent_client_session.py +44 -1
- mcp_agent/mcp/mcp_aggregator.py +56 -11
- mcp_agent/mcp/mcp_connection_manager.py +30 -18
- mcp_agent/mcp_server/agent_server.py +2 -0
- mcp_agent/mcp_server_registry.py +16 -8
- mcp_agent/resources/examples/data-analysis/analysis.py +1 -2
- mcp_agent/resources/examples/mcp/elicitations/README.md +157 -0
- mcp_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
- mcp_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +232 -0
- mcp_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
- mcp_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
- mcp_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
- mcp_agent/resources/examples/mcp/elicitations/forms_demo.py +111 -0
- mcp_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
- mcp_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
- mcp_agent/resources/examples/{prompting/agent.py → mcp/elicitations/tool_call.py} +4 -5
- mcp_agent/resources/examples/mcp/state-transfer/agent_two.py +1 -1
- mcp_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +1 -1
- mcp_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +1 -0
- mcp_agent/resources/examples/workflows/evaluator.py +1 -1
- mcp_agent/resources/examples/workflows/graded_report.md +89 -0
- mcp_agent/resources/examples/workflows/orchestrator.py +7 -9
- mcp_agent/resources/examples/workflows/parallel.py +0 -2
- mcp_agent/resources/examples/workflows/short_story.md +13 -0
- mcp_agent/resources/examples/in_dev/agent_build.py +0 -84
- mcp_agent/resources/examples/in_dev/css-LICENSE.txt +0 -21
- mcp_agent/resources/examples/in_dev/slides.py +0 -110
- mcp_agent/resources/examples/internal/agent.py +0 -20
- mcp_agent/resources/examples/internal/fastagent.config.yaml +0 -66
- mcp_agent/resources/examples/internal/history_transfer.py +0 -35
- mcp_agent/resources/examples/internal/job.py +0 -84
- mcp_agent/resources/examples/internal/prompt_category.py +0 -21
- mcp_agent/resources/examples/internal/prompt_sizing.py +0 -51
- mcp_agent/resources/examples/internal/simple.txt +0 -2
- mcp_agent/resources/examples/internal/sizer.py +0 -20
- mcp_agent/resources/examples/internal/social.py +0 -67
- mcp_agent/resources/examples/prompting/__init__.py +0 -3
- mcp_agent/resources/examples/prompting/delimited_prompt.txt +0 -14
- mcp_agent/resources/examples/prompting/fastagent.config.yaml +0 -43
- mcp_agent/resources/examples/prompting/image_server.py +0 -52
- mcp_agent/resources/examples/prompting/prompt1.txt +0 -6
- mcp_agent/resources/examples/prompting/work_with_image.py +0 -19
- {fast_agent_mcp-0.2.35.dist-info → fast_agent_mcp-0.2.37.dist-info}/WHEEL +0 -0
- {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()
|
mcp_agent/llm/augmented_llm.py
CHANGED
|
@@ -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
|
-
#
|
|
115
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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) ->
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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) ->
|
|
108
|
+
def _openai_client(self) -> AsyncOpenAI:
|
|
107
109
|
try:
|
|
108
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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 =
|
|
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": "[
|
|
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
|
-
|
|
391
|
-
|
|
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 = {
|