fast-agent-mcp 0.3.15__py3-none-any.whl → 0.3.17__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/__init__.py +2 -0
- fast_agent/agents/agent_types.py +5 -0
- fast_agent/agents/llm_agent.py +7 -0
- fast_agent/agents/llm_decorator.py +6 -0
- fast_agent/agents/mcp_agent.py +134 -10
- fast_agent/cli/__main__.py +35 -0
- fast_agent/cli/commands/check_config.py +85 -0
- fast_agent/cli/commands/go.py +100 -36
- fast_agent/cli/constants.py +15 -1
- fast_agent/cli/main.py +2 -1
- fast_agent/config.py +39 -10
- fast_agent/constants.py +8 -0
- fast_agent/context.py +24 -15
- fast_agent/core/direct_decorators.py +9 -0
- fast_agent/core/fastagent.py +101 -1
- fast_agent/core/logging/listeners.py +8 -0
- fast_agent/interfaces.py +12 -0
- fast_agent/llm/fastagent_llm.py +45 -0
- fast_agent/llm/memory.py +26 -1
- fast_agent/llm/model_database.py +4 -1
- fast_agent/llm/model_factory.py +4 -2
- fast_agent/llm/model_info.py +19 -43
- fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
- fast_agent/llm/provider/google/llm_google_native.py +238 -7
- fast_agent/llm/provider/openai/llm_openai.py +382 -19
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/resources/setup/agent.py +2 -0
- fast_agent/resources/setup/fastagent.config.yaml +6 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +208 -0
- fast_agent/tools/shell_runtime.py +404 -0
- fast_agent/ui/console_display.py +47 -996
- fast_agent/ui/elicitation_form.py +76 -24
- fast_agent/ui/elicitation_style.py +2 -2
- fast_agent/ui/enhanced_prompt.py +107 -37
- fast_agent/ui/history_display.py +20 -5
- fast_agent/ui/interactive_prompt.py +108 -3
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +103 -45
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/streaming.py +638 -0
- fast_agent/ui/tool_display.py +417 -0
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/METADATA +8 -7
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/RECORD +47 -39
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/licenses/LICENSE +0 -0
|
@@ -7,7 +7,7 @@ from mcp.types import (
|
|
|
7
7
|
ContentBlock,
|
|
8
8
|
TextContent,
|
|
9
9
|
)
|
|
10
|
-
from openai import APIError, AsyncOpenAI, AuthenticationError
|
|
10
|
+
from openai import APIError, AsyncOpenAI, AuthenticationError, DefaultAioHttpClient
|
|
11
11
|
from openai.lib.streaming.chat import ChatCompletionStreamState
|
|
12
12
|
|
|
13
13
|
# from openai.types.beta.chat import
|
|
@@ -95,9 +95,19 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
|
|
|
95
95
|
return self.context.config.openai.base_url if self.context.config.openai else None
|
|
96
96
|
|
|
97
97
|
def _openai_client(self) -> AsyncOpenAI:
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
"""
|
|
99
|
+
Create an OpenAI client instance.
|
|
100
|
+
Subclasses can override this to provide different client types (e.g., AzureOpenAI).
|
|
100
101
|
|
|
102
|
+
Note: The returned client should be used within an async context manager
|
|
103
|
+
to ensure proper cleanup of aiohttp sessions.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
return AsyncOpenAI(
|
|
107
|
+
api_key=self._api_key(),
|
|
108
|
+
base_url=self._base_url(),
|
|
109
|
+
http_client=DefaultAioHttpClient(),
|
|
110
|
+
)
|
|
101
111
|
except AuthenticationError as e:
|
|
102
112
|
raise ProviderKeyError(
|
|
103
113
|
"Invalid OpenAI API key",
|
|
@@ -105,6 +115,96 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
|
|
|
105
115
|
"Please check that your API key is valid and not expired.",
|
|
106
116
|
) from e
|
|
107
117
|
|
|
118
|
+
def _streams_tool_arguments(self) -> bool:
|
|
119
|
+
"""
|
|
120
|
+
Determine whether the current provider streams tool call arguments incrementally.
|
|
121
|
+
|
|
122
|
+
Official OpenAI and Azure OpenAI endpoints stream arguments. Most third-party
|
|
123
|
+
OpenAI-compatible gateways (e.g. OpenRouter, Moonshot) deliver the full arguments
|
|
124
|
+
once, so we should treat them as non-streaming to restore the legacy \"Calling Tool\"
|
|
125
|
+
display experience.
|
|
126
|
+
"""
|
|
127
|
+
if self.provider == Provider.AZURE:
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
if self.provider == Provider.OPENAI:
|
|
131
|
+
base_url = self._base_url()
|
|
132
|
+
if not base_url:
|
|
133
|
+
return True
|
|
134
|
+
lowered = base_url.lower()
|
|
135
|
+
return "api.openai" in lowered or "openai.azure" in lowered or "azure.com" in lowered
|
|
136
|
+
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def _emit_tool_notification_fallback(
|
|
140
|
+
self,
|
|
141
|
+
tool_calls: Any,
|
|
142
|
+
notified_indices: set[int],
|
|
143
|
+
*,
|
|
144
|
+
streams_arguments: bool,
|
|
145
|
+
model: str,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Emit start/stop notifications when streaming metadata was missing."""
|
|
148
|
+
if not tool_calls:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
for index, tool_call in enumerate(tool_calls):
|
|
152
|
+
if index in notified_indices:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
tool_name = None
|
|
156
|
+
tool_use_id = None
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
tool_use_id = getattr(tool_call, "id", None)
|
|
160
|
+
function = getattr(tool_call, "function", None)
|
|
161
|
+
if function:
|
|
162
|
+
tool_name = getattr(function, "name", None)
|
|
163
|
+
except Exception:
|
|
164
|
+
tool_use_id = None
|
|
165
|
+
tool_name = None
|
|
166
|
+
|
|
167
|
+
if not tool_name:
|
|
168
|
+
tool_name = "tool"
|
|
169
|
+
if not tool_use_id:
|
|
170
|
+
tool_use_id = f"tool-{index}"
|
|
171
|
+
|
|
172
|
+
payload = {
|
|
173
|
+
"tool_name": tool_name,
|
|
174
|
+
"tool_use_id": tool_use_id,
|
|
175
|
+
"index": index,
|
|
176
|
+
"streams_arguments": streams_arguments,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
self._notify_tool_stream_listeners("start", payload)
|
|
180
|
+
self.logger.info(
|
|
181
|
+
"Model emitted fallback tool notification",
|
|
182
|
+
data={
|
|
183
|
+
"progress_action": ProgressAction.CALLING_TOOL,
|
|
184
|
+
"agent_name": self.name,
|
|
185
|
+
"model": model,
|
|
186
|
+
"tool_name": tool_name,
|
|
187
|
+
"tool_use_id": tool_use_id,
|
|
188
|
+
"tool_event": "start",
|
|
189
|
+
"streams_arguments": streams_arguments,
|
|
190
|
+
"fallback": True,
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
self._notify_tool_stream_listeners("stop", payload)
|
|
194
|
+
self.logger.info(
|
|
195
|
+
"Model emitted fallback tool notification",
|
|
196
|
+
data={
|
|
197
|
+
"progress_action": ProgressAction.CALLING_TOOL,
|
|
198
|
+
"agent_name": self.name,
|
|
199
|
+
"model": model,
|
|
200
|
+
"tool_name": tool_name,
|
|
201
|
+
"tool_use_id": tool_use_id,
|
|
202
|
+
"tool_event": "stop",
|
|
203
|
+
"streams_arguments": streams_arguments,
|
|
204
|
+
"fallback": True,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
|
|
108
208
|
async def _process_stream(self, stream, model: str):
|
|
109
209
|
"""Process the streaming response and display real-time token usage."""
|
|
110
210
|
# Track estimated output tokens by counting text chunks
|
|
@@ -113,22 +213,145 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
|
|
|
113
213
|
# For non-OpenAI providers (like Ollama), ChatCompletionStreamState might not work correctly
|
|
114
214
|
# Fall back to manual accumulation if needed
|
|
115
215
|
# TODO -- consider this and whether to subclass instead
|
|
116
|
-
if self.provider in [
|
|
216
|
+
if self.provider in [
|
|
217
|
+
Provider.GENERIC,
|
|
218
|
+
Provider.OPENROUTER,
|
|
219
|
+
Provider.GOOGLE_OAI,
|
|
220
|
+
]:
|
|
117
221
|
return await self._process_stream_manual(stream, model)
|
|
118
222
|
|
|
119
223
|
# Use ChatCompletionStreamState helper for accumulation (OpenAI only)
|
|
120
224
|
state = ChatCompletionStreamState()
|
|
121
225
|
|
|
226
|
+
# Track tool call state for stream events
|
|
227
|
+
tool_call_started: dict[int, dict[str, Any]] = {}
|
|
228
|
+
streams_arguments = self._streams_tool_arguments()
|
|
229
|
+
notified_tool_indices: set[int] = set()
|
|
230
|
+
|
|
122
231
|
# Process the stream chunks
|
|
123
232
|
async for chunk in stream:
|
|
124
233
|
# Handle chunk accumulation
|
|
125
234
|
state.handle_chunk(chunk)
|
|
235
|
+
# Process streaming events for tool calls
|
|
236
|
+
if chunk.choices:
|
|
237
|
+
choice = chunk.choices[0]
|
|
238
|
+
delta = choice.delta
|
|
239
|
+
|
|
240
|
+
# Handle tool call streaming
|
|
241
|
+
if delta.tool_calls:
|
|
242
|
+
for tool_call in delta.tool_calls:
|
|
243
|
+
index = tool_call.index
|
|
244
|
+
|
|
245
|
+
# Fire "start" event on first chunk for this tool call
|
|
246
|
+
if index is None:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
existing_info = tool_call_started.get(index)
|
|
250
|
+
tool_use_id = tool_call.id or (
|
|
251
|
+
existing_info.get("tool_use_id") if existing_info else None
|
|
252
|
+
)
|
|
253
|
+
function_name = (
|
|
254
|
+
tool_call.function.name
|
|
255
|
+
if tool_call.function and tool_call.function.name
|
|
256
|
+
else (existing_info.get("tool_name") if existing_info else None)
|
|
257
|
+
)
|
|
126
258
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
259
|
+
if existing_info is None and tool_use_id and function_name:
|
|
260
|
+
tool_call_started[index] = {
|
|
261
|
+
"tool_name": function_name,
|
|
262
|
+
"tool_use_id": tool_use_id,
|
|
263
|
+
"streams_arguments": streams_arguments,
|
|
264
|
+
}
|
|
265
|
+
self._notify_tool_stream_listeners(
|
|
266
|
+
"start",
|
|
267
|
+
{
|
|
268
|
+
"tool_name": function_name,
|
|
269
|
+
"tool_use_id": tool_use_id,
|
|
270
|
+
"index": index,
|
|
271
|
+
"streams_arguments": streams_arguments,
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
self.logger.info(
|
|
275
|
+
"Model started streaming tool call",
|
|
276
|
+
data={
|
|
277
|
+
"progress_action": ProgressAction.CALLING_TOOL,
|
|
278
|
+
"agent_name": self.name,
|
|
279
|
+
"model": model,
|
|
280
|
+
"tool_name": function_name,
|
|
281
|
+
"tool_use_id": tool_use_id,
|
|
282
|
+
"tool_event": "start",
|
|
283
|
+
"streams_arguments": streams_arguments,
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
notified_tool_indices.add(index)
|
|
287
|
+
elif existing_info:
|
|
288
|
+
if tool_use_id:
|
|
289
|
+
existing_info["tool_use_id"] = tool_use_id
|
|
290
|
+
if function_name:
|
|
291
|
+
existing_info["tool_name"] = function_name
|
|
292
|
+
|
|
293
|
+
# Fire "delta" event for argument chunks
|
|
294
|
+
if tool_call.function and tool_call.function.arguments:
|
|
295
|
+
info = tool_call_started.setdefault(
|
|
296
|
+
index,
|
|
297
|
+
{
|
|
298
|
+
"tool_name": function_name,
|
|
299
|
+
"tool_use_id": tool_use_id,
|
|
300
|
+
"streams_arguments": streams_arguments,
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
self._notify_tool_stream_listeners(
|
|
304
|
+
"delta",
|
|
305
|
+
{
|
|
306
|
+
"tool_name": info.get("tool_name"),
|
|
307
|
+
"tool_use_id": info.get("tool_use_id"),
|
|
308
|
+
"index": index,
|
|
309
|
+
"chunk": tool_call.function.arguments,
|
|
310
|
+
"streams_arguments": info.get("streams_arguments", False),
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Handle text content streaming
|
|
315
|
+
if delta.content:
|
|
316
|
+
content = delta.content
|
|
317
|
+
# Use base class method for token estimation and progress emission
|
|
318
|
+
estimated_tokens = self._update_streaming_progress(
|
|
319
|
+
content, model, estimated_tokens
|
|
320
|
+
)
|
|
321
|
+
self._notify_tool_stream_listeners(
|
|
322
|
+
"text",
|
|
323
|
+
{
|
|
324
|
+
"chunk": content,
|
|
325
|
+
"streams_arguments": streams_arguments,
|
|
326
|
+
},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Fire "stop" event when tool calls complete
|
|
330
|
+
if choice.finish_reason == "tool_calls":
|
|
331
|
+
for index, info in list(tool_call_started.items()):
|
|
332
|
+
self._notify_tool_stream_listeners(
|
|
333
|
+
"stop",
|
|
334
|
+
{
|
|
335
|
+
"tool_name": info.get("tool_name"),
|
|
336
|
+
"tool_use_id": info.get("tool_use_id"),
|
|
337
|
+
"index": index,
|
|
338
|
+
"streams_arguments": info.get("streams_arguments", False),
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
self.logger.info(
|
|
342
|
+
"Model finished streaming tool call",
|
|
343
|
+
data={
|
|
344
|
+
"progress_action": ProgressAction.CALLING_TOOL,
|
|
345
|
+
"agent_name": self.name,
|
|
346
|
+
"model": model,
|
|
347
|
+
"tool_name": info.get("tool_name"),
|
|
348
|
+
"tool_use_id": info.get("tool_use_id"),
|
|
349
|
+
"tool_event": "stop",
|
|
350
|
+
"streams_arguments": info.get("streams_arguments", False),
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
notified_tool_indices.add(index)
|
|
354
|
+
tool_call_started.clear()
|
|
132
355
|
|
|
133
356
|
# Check if we hit the length limit to avoid LengthFinishReasonError
|
|
134
357
|
current_snapshot = state.current_completion_snapshot
|
|
@@ -157,12 +380,24 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
|
|
|
157
380
|
f"Streaming complete - Model: {model}, Input tokens: {final_completion.usage.prompt_tokens}, Output tokens: {final_completion.usage.completion_tokens}"
|
|
158
381
|
)
|
|
159
382
|
|
|
383
|
+
final_message = None
|
|
384
|
+
if hasattr(final_completion, "choices") and final_completion.choices:
|
|
385
|
+
final_message = getattr(final_completion.choices[0], "message", None)
|
|
386
|
+
tool_calls = getattr(final_message, "tool_calls", None) if final_message else None
|
|
387
|
+
self._emit_tool_notification_fallback(
|
|
388
|
+
tool_calls,
|
|
389
|
+
notified_tool_indices,
|
|
390
|
+
streams_arguments=streams_arguments,
|
|
391
|
+
model=model,
|
|
392
|
+
)
|
|
393
|
+
|
|
160
394
|
return final_completion
|
|
161
395
|
|
|
162
396
|
# TODO - as per other comment this needs to go in another class. There are a number of "special" cases dealt with
|
|
163
397
|
# here to deal with OpenRouter idiosyncrasies between e.g. Anthropic and Gemini models.
|
|
164
398
|
async def _process_stream_manual(self, stream, model: str):
|
|
165
399
|
"""Manual stream processing for providers like Ollama that may not work with ChatCompletionStreamState."""
|
|
400
|
+
|
|
166
401
|
from openai.types.chat import ChatCompletionMessageToolCall
|
|
167
402
|
|
|
168
403
|
# Track estimated output tokens by counting text chunks
|
|
@@ -176,14 +411,132 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
|
|
|
176
411
|
finish_reason = None
|
|
177
412
|
usage_data = None
|
|
178
413
|
|
|
414
|
+
# Track tool call state for stream events
|
|
415
|
+
tool_call_started: dict[int, dict[str, Any]] = {}
|
|
416
|
+
streams_arguments = self._streams_tool_arguments()
|
|
417
|
+
notified_tool_indices: set[int] = set()
|
|
418
|
+
|
|
179
419
|
# Process the stream chunks manually
|
|
180
420
|
async for chunk in stream:
|
|
181
|
-
#
|
|
182
|
-
if chunk.choices
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
421
|
+
# Process streaming events for tool calls
|
|
422
|
+
if chunk.choices:
|
|
423
|
+
choice = chunk.choices[0]
|
|
424
|
+
delta = choice.delta
|
|
425
|
+
|
|
426
|
+
# Handle tool call streaming
|
|
427
|
+
if delta.tool_calls:
|
|
428
|
+
for tool_call in delta.tool_calls:
|
|
429
|
+
if tool_call.index is not None:
|
|
430
|
+
index = tool_call.index
|
|
431
|
+
|
|
432
|
+
existing_info = tool_call_started.get(index)
|
|
433
|
+
tool_use_id = tool_call.id or (
|
|
434
|
+
existing_info.get("tool_use_id") if existing_info else None
|
|
435
|
+
)
|
|
436
|
+
function_name = (
|
|
437
|
+
tool_call.function.name
|
|
438
|
+
if tool_call.function and tool_call.function.name
|
|
439
|
+
else (existing_info.get("tool_name") if existing_info else None)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Fire "start" event on first chunk for this tool call
|
|
443
|
+
if index not in tool_call_started and tool_use_id and function_name:
|
|
444
|
+
tool_call_started[index] = {
|
|
445
|
+
"tool_name": function_name,
|
|
446
|
+
"tool_use_id": tool_use_id,
|
|
447
|
+
"streams_arguments": streams_arguments,
|
|
448
|
+
}
|
|
449
|
+
self._notify_tool_stream_listeners(
|
|
450
|
+
"start",
|
|
451
|
+
{
|
|
452
|
+
"tool_name": function_name,
|
|
453
|
+
"tool_use_id": tool_use_id,
|
|
454
|
+
"index": index,
|
|
455
|
+
"streams_arguments": streams_arguments,
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
self.logger.info(
|
|
459
|
+
"Model started streaming tool call",
|
|
460
|
+
data={
|
|
461
|
+
"progress_action": ProgressAction.CALLING_TOOL,
|
|
462
|
+
"agent_name": self.name,
|
|
463
|
+
"model": model,
|
|
464
|
+
"tool_name": function_name,
|
|
465
|
+
"tool_use_id": tool_use_id,
|
|
466
|
+
"tool_event": "start",
|
|
467
|
+
"streams_arguments": streams_arguments,
|
|
468
|
+
},
|
|
469
|
+
)
|
|
470
|
+
notified_tool_indices.add(index)
|
|
471
|
+
elif existing_info:
|
|
472
|
+
if tool_use_id:
|
|
473
|
+
existing_info["tool_use_id"] = tool_use_id
|
|
474
|
+
if function_name:
|
|
475
|
+
existing_info["tool_name"] = function_name
|
|
476
|
+
|
|
477
|
+
# Fire "delta" event for argument chunks
|
|
478
|
+
if tool_call.function and tool_call.function.arguments:
|
|
479
|
+
info = tool_call_started.setdefault(
|
|
480
|
+
index,
|
|
481
|
+
{
|
|
482
|
+
"tool_name": function_name,
|
|
483
|
+
"tool_use_id": tool_use_id,
|
|
484
|
+
"streams_arguments": streams_arguments,
|
|
485
|
+
},
|
|
486
|
+
)
|
|
487
|
+
self._notify_tool_stream_listeners(
|
|
488
|
+
"delta",
|
|
489
|
+
{
|
|
490
|
+
"tool_name": info.get("tool_name"),
|
|
491
|
+
"tool_use_id": info.get("tool_use_id"),
|
|
492
|
+
"index": index,
|
|
493
|
+
"chunk": tool_call.function.arguments,
|
|
494
|
+
"streams_arguments": info.get("streams_arguments", False),
|
|
495
|
+
},
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Handle text content streaming
|
|
499
|
+
if delta.content:
|
|
500
|
+
content = delta.content
|
|
501
|
+
accumulated_content += content
|
|
502
|
+
# Use base class method for token estimation and progress emission
|
|
503
|
+
estimated_tokens = self._update_streaming_progress(
|
|
504
|
+
content, model, estimated_tokens
|
|
505
|
+
)
|
|
506
|
+
self._notify_tool_stream_listeners(
|
|
507
|
+
"text",
|
|
508
|
+
{
|
|
509
|
+
"chunk": content,
|
|
510
|
+
"streams_arguments": streams_arguments,
|
|
511
|
+
},
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# Fire "stop" event when tool calls complete
|
|
515
|
+
if choice.finish_reason == "tool_calls":
|
|
516
|
+
for index, info in list(tool_call_started.items()):
|
|
517
|
+
self._notify_tool_stream_listeners(
|
|
518
|
+
"stop",
|
|
519
|
+
{
|
|
520
|
+
"tool_name": info.get("tool_name"),
|
|
521
|
+
"tool_use_id": info.get("tool_use_id"),
|
|
522
|
+
"index": index,
|
|
523
|
+
"streams_arguments": info.get("streams_arguments", False),
|
|
524
|
+
},
|
|
525
|
+
)
|
|
526
|
+
self.logger.info(
|
|
527
|
+
"Model finished streaming tool call",
|
|
528
|
+
data={
|
|
529
|
+
"progress_action": ProgressAction.CALLING_TOOL,
|
|
530
|
+
"agent_name": self.name,
|
|
531
|
+
"model": model,
|
|
532
|
+
"tool_name": info.get("tool_name"),
|
|
533
|
+
"tool_use_id": info.get("tool_use_id"),
|
|
534
|
+
"tool_event": "stop",
|
|
535
|
+
"streams_arguments": info.get("streams_arguments", False),
|
|
536
|
+
},
|
|
537
|
+
)
|
|
538
|
+
notified_tool_indices.add(index)
|
|
539
|
+
tool_call_started.clear()
|
|
187
540
|
|
|
188
541
|
# Extract other fields from the chunk
|
|
189
542
|
if chunk.choices:
|
|
@@ -284,6 +637,15 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
|
|
|
284
637
|
f"Streaming complete - Model: {model}, Input tokens: {getattr(usage_data, 'prompt_tokens', 0)}, Output tokens: {actual_tokens}"
|
|
285
638
|
)
|
|
286
639
|
|
|
640
|
+
final_message = final_completion.choices[0].message if final_completion.choices else None
|
|
641
|
+
tool_calls = getattr(final_message, "tool_calls", None) if final_message else None
|
|
642
|
+
self._emit_tool_notification_fallback(
|
|
643
|
+
tool_calls,
|
|
644
|
+
notified_tool_indices,
|
|
645
|
+
streams_arguments=streams_arguments,
|
|
646
|
+
model=model,
|
|
647
|
+
)
|
|
648
|
+
|
|
287
649
|
return final_completion
|
|
288
650
|
|
|
289
651
|
async def _openai_completion(
|
|
@@ -343,11 +705,12 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
|
|
|
343
705
|
self._log_chat_progress(self.chat_turn(), model=self.default_request_params.model)
|
|
344
706
|
model_name = self.default_request_params.model or DEFAULT_OPENAI_MODEL
|
|
345
707
|
|
|
346
|
-
# Use basic streaming API
|
|
708
|
+
# Use basic streaming API with context manager to properly close aiohttp session
|
|
347
709
|
try:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
710
|
+
async with self._openai_client() as client:
|
|
711
|
+
stream = await client.chat.completions.create(**arguments)
|
|
712
|
+
# Process the stream
|
|
713
|
+
response = await self._process_stream(stream, model_name)
|
|
351
714
|
except APIError as error:
|
|
352
715
|
self.logger.error("APIError during OpenAI completion", exc_info=error)
|
|
353
716
|
return self._stream_failure_response(error, model_name)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# from openai.types.beta.chat import
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from mcp import Tool
|
|
5
|
+
from mcp.types import ContentBlock, TextContent
|
|
6
|
+
from openai import AsyncOpenAI
|
|
7
|
+
from openai.types.chat import (
|
|
8
|
+
ChatCompletionMessage,
|
|
9
|
+
ChatCompletionMessageParam,
|
|
10
|
+
)
|
|
11
|
+
from openai.types.responses import (
|
|
12
|
+
ResponseReasoningItem,
|
|
13
|
+
ResponseReasoningSummaryTextDeltaEvent,
|
|
14
|
+
ResponseTextDeltaEvent,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from fast_agent.constants import REASONING
|
|
18
|
+
from fast_agent.core.logging.logger import get_logger
|
|
19
|
+
from fast_agent.event_progress import ProgressAction
|
|
20
|
+
from fast_agent.llm.fastagent_llm import FastAgentLLM
|
|
21
|
+
from fast_agent.llm.provider_types import Provider
|
|
22
|
+
from fast_agent.llm.request_params import RequestParams
|
|
23
|
+
from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
|
|
24
|
+
from fast_agent.types.llm_stop_reason import LlmStopReason
|
|
25
|
+
|
|
26
|
+
_logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
DEFAULT_RESPONSES_MODEL = "gpt-5-mini"
|
|
29
|
+
DEFAULT_REASONING_EFFORT = "medium"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# model selection
|
|
33
|
+
# system prompt
|
|
34
|
+
# usage info
|
|
35
|
+
# reasoning/thinking display and summary
|
|
36
|
+
# encrypted tokens
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ResponsesLLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage]):
|
|
40
|
+
"""LLM implementation for OpenAI's Responses models."""
|
|
41
|
+
|
|
42
|
+
# OpenAI-specific parameter exclusions
|
|
43
|
+
|
|
44
|
+
def __init__(self, provider=Provider.RESPONSES, *args, **kwargs):
|
|
45
|
+
super().__init__(*args, provider=provider, **kwargs)
|
|
46
|
+
|
|
47
|
+
async def _responses_client(self) -> AsyncOpenAI:
|
|
48
|
+
return AsyncOpenAI(api_key=self._api_key())
|
|
49
|
+
|
|
50
|
+
async def _apply_prompt_provider_specific(
|
|
51
|
+
self,
|
|
52
|
+
multipart_messages: List[PromptMessageExtended],
|
|
53
|
+
request_params: RequestParams | None = None,
|
|
54
|
+
tools: List[Tool] | None = None,
|
|
55
|
+
is_template: bool = False,
|
|
56
|
+
) -> PromptMessageExtended:
|
|
57
|
+
responses_client = await self._responses_client()
|
|
58
|
+
|
|
59
|
+
async with responses_client.responses.stream(
|
|
60
|
+
model="gpt-5-mini",
|
|
61
|
+
instructions="You are a helpful assistant.",
|
|
62
|
+
input=multipart_messages[-1].all_text(),
|
|
63
|
+
reasoning={"summary": "auto", "effort": DEFAULT_REASONING_EFFORT},
|
|
64
|
+
) as stream:
|
|
65
|
+
reasoning_chars: int = 0
|
|
66
|
+
text_chars: int = 0
|
|
67
|
+
|
|
68
|
+
async for event in stream:
|
|
69
|
+
if isinstance(event, ResponseReasoningSummaryTextDeltaEvent):
|
|
70
|
+
reasoning_chars += len(event.delta)
|
|
71
|
+
await self._emit_streaming_progress(
|
|
72
|
+
model="gpt-5-mini (thinking)",
|
|
73
|
+
new_total=reasoning_chars,
|
|
74
|
+
type=ProgressAction.THINKING,
|
|
75
|
+
)
|
|
76
|
+
if isinstance(event, ResponseTextDeltaEvent):
|
|
77
|
+
# Notify stream listeners with the delta text
|
|
78
|
+
self._notify_stream_listeners(event.delta)
|
|
79
|
+
text_chars += len(event.delta)
|
|
80
|
+
await self._emit_streaming_progress(
|
|
81
|
+
model="gpt-5-mini",
|
|
82
|
+
new_total=text_chars,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
final_response = await stream.get_final_response()
|
|
86
|
+
reasoning_content: List[ContentBlock] = []
|
|
87
|
+
for output_item in final_response.output:
|
|
88
|
+
if isinstance(output_item, ResponseReasoningItem):
|
|
89
|
+
summary_text = "\n".join(part.text for part in output_item.summary if part.text)
|
|
90
|
+
# reasoning text is not supplied by openai - leaving for future use with other providers
|
|
91
|
+
reasoning_text = "".join(
|
|
92
|
+
chunk.text
|
|
93
|
+
for chunk in (output_item.content or [])
|
|
94
|
+
if chunk.type == "reasoning_text"
|
|
95
|
+
)
|
|
96
|
+
if summary_text.strip():
|
|
97
|
+
reasoning_content.append(TextContent(type="text", text=summary_text.strip()))
|
|
98
|
+
if reasoning_text.strip():
|
|
99
|
+
reasoning_content.append(
|
|
100
|
+
TextContent(type="text", text=reasoning_text.strip())
|
|
101
|
+
)
|
|
102
|
+
channels = {REASONING: reasoning_content} if reasoning_content else None
|
|
103
|
+
|
|
104
|
+
return PromptMessageExtended(
|
|
105
|
+
role="assistant",
|
|
106
|
+
channels=channels,
|
|
107
|
+
content=[TextContent(type="text", text=final_response.output_text)],
|
|
108
|
+
stop_reason=LlmStopReason.END_TURN,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def _emit_streaming_progress(
|
|
112
|
+
self,
|
|
113
|
+
model: str,
|
|
114
|
+
new_total: int,
|
|
115
|
+
type: ProgressAction = ProgressAction.STREAMING,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Emit a streaming progress event.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
model: The model being used.
|
|
121
|
+
new_total: The new total token count.
|
|
122
|
+
"""
|
|
123
|
+
token_str = str(new_total).rjust(5)
|
|
124
|
+
|
|
125
|
+
# Emit progress event
|
|
126
|
+
data = {
|
|
127
|
+
"progress_action": type,
|
|
128
|
+
"model": model,
|
|
129
|
+
"agent_name": self.name,
|
|
130
|
+
"chat_turn": self.chat_turn(),
|
|
131
|
+
"details": token_str.strip(), # Token count goes in details for STREAMING action
|
|
132
|
+
}
|
|
133
|
+
self.logger.info("Streaming progress", data=data)
|
|
@@ -20,6 +20,12 @@ mcp_timeline:
|
|
|
20
20
|
steps: 20 # number of timeline buckets to render
|
|
21
21
|
step_seconds: 15 # seconds per bucket (accepts values like "45s", "2m")
|
|
22
22
|
|
|
23
|
+
#shell_execution:
|
|
24
|
+
# length of time before terminating subprocess
|
|
25
|
+
# timeout_seconds: 20
|
|
26
|
+
# warning interval if no output seen
|
|
27
|
+
# warning_seconds: 5
|
|
28
|
+
|
|
23
29
|
# Logging and Console Configuration:
|
|
24
30
|
logger:
|
|
25
31
|
# level: "debug" | "info" | "warning" | "error"
|