fast-agent-mcp 0.3.16__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.

@@ -115,6 +115,96 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
115
115
  "Please check that your API key is valid and not expired.",
116
116
  ) from e
117
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
+
118
208
  async def _process_stream(self, stream, model: str):
119
209
  """Process the streaming response and display real-time token usage."""
120
210
  # Track estimated output tokens by counting text chunks
@@ -123,20 +213,25 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
123
213
  # For non-OpenAI providers (like Ollama), ChatCompletionStreamState might not work correctly
124
214
  # Fall back to manual accumulation if needed
125
215
  # TODO -- consider this and whether to subclass instead
126
- if self.provider in [Provider.GENERIC, Provider.OPENROUTER, Provider.GOOGLE_OAI]:
216
+ if self.provider in [
217
+ Provider.GENERIC,
218
+ Provider.OPENROUTER,
219
+ Provider.GOOGLE_OAI,
220
+ ]:
127
221
  return await self._process_stream_manual(stream, model)
128
222
 
129
223
  # Use ChatCompletionStreamState helper for accumulation (OpenAI only)
130
224
  state = ChatCompletionStreamState()
131
225
 
132
226
  # Track tool call state for stream events
133
- tool_call_started = {} # Maps index -> bool for tracking start events
227
+ tool_call_started: dict[int, dict[str, Any]] = {}
228
+ streams_arguments = self._streams_tool_arguments()
229
+ notified_tool_indices: set[int] = set()
134
230
 
135
231
  # Process the stream chunks
136
232
  async for chunk in stream:
137
233
  # Handle chunk accumulation
138
234
  state.handle_chunk(chunk)
139
-
140
235
  # Process streaming events for tool calls
141
236
  if chunk.choices:
142
237
  choice = chunk.choices[0]
@@ -148,15 +243,32 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
148
243
  index = tool_call.index
149
244
 
150
245
  # Fire "start" event on first chunk for this tool call
151
- if index not in tool_call_started and tool_call.id and tool_call.function and tool_call.function.name:
152
- tool_call_started[index] = True
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
+ )
258
+
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
+ }
153
265
  self._notify_tool_stream_listeners(
154
266
  "start",
155
267
  {
156
- "tool_name": tool_call.function.name,
157
- "tool_use_id": tool_call.id,
268
+ "tool_name": function_name,
269
+ "tool_use_id": tool_use_id,
158
270
  "index": index,
159
- "streams_arguments": True, # OpenAI streams arguments!
271
+ "streams_arguments": streams_arguments,
160
272
  },
161
273
  )
162
274
  self.logger.info(
@@ -165,22 +277,37 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
165
277
  "progress_action": ProgressAction.CALLING_TOOL,
166
278
  "agent_name": self.name,
167
279
  "model": model,
168
- "tool_name": tool_call.function.name,
169
- "tool_use_id": tool_call.id,
280
+ "tool_name": function_name,
281
+ "tool_use_id": tool_use_id,
170
282
  "tool_event": "start",
283
+ "streams_arguments": streams_arguments,
171
284
  },
172
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
173
292
 
174
293
  # Fire "delta" event for argument chunks
175
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
+ )
176
303
  self._notify_tool_stream_listeners(
177
304
  "delta",
178
305
  {
179
- "tool_name": tool_call.function.name if tool_call.function.name else None,
180
- "tool_use_id": tool_call.id,
306
+ "tool_name": info.get("tool_name"),
307
+ "tool_use_id": info.get("tool_use_id"),
181
308
  "index": index,
182
309
  "chunk": tool_call.function.arguments,
183
- "streams_arguments": True,
310
+ "streams_arguments": info.get("streams_arguments", False),
184
311
  },
185
312
  )
186
313
 
@@ -188,23 +315,27 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
188
315
  if delta.content:
189
316
  content = delta.content
190
317
  # Use base class method for token estimation and progress emission
191
- estimated_tokens = self._update_streaming_progress(content, model, estimated_tokens)
318
+ estimated_tokens = self._update_streaming_progress(
319
+ content, model, estimated_tokens
320
+ )
192
321
  self._notify_tool_stream_listeners(
193
322
  "text",
194
323
  {
195
324
  "chunk": content,
196
- "streams_arguments": True,
325
+ "streams_arguments": streams_arguments,
197
326
  },
198
327
  )
199
328
 
200
329
  # Fire "stop" event when tool calls complete
201
330
  if choice.finish_reason == "tool_calls":
202
- for index in tool_call_started.keys():
331
+ for index, info in list(tool_call_started.items()):
203
332
  self._notify_tool_stream_listeners(
204
333
  "stop",
205
334
  {
335
+ "tool_name": info.get("tool_name"),
336
+ "tool_use_id": info.get("tool_use_id"),
206
337
  "index": index,
207
- "streams_arguments": True,
338
+ "streams_arguments": info.get("streams_arguments", False),
208
339
  },
209
340
  )
210
341
  self.logger.info(
@@ -213,9 +344,14 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
213
344
  "progress_action": ProgressAction.CALLING_TOOL,
214
345
  "agent_name": self.name,
215
346
  "model": model,
347
+ "tool_name": info.get("tool_name"),
348
+ "tool_use_id": info.get("tool_use_id"),
216
349
  "tool_event": "stop",
350
+ "streams_arguments": info.get("streams_arguments", False),
217
351
  },
218
352
  )
353
+ notified_tool_indices.add(index)
354
+ tool_call_started.clear()
219
355
 
220
356
  # Check if we hit the length limit to avoid LengthFinishReasonError
221
357
  current_snapshot = state.current_completion_snapshot
@@ -244,12 +380,24 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
244
380
  f"Streaming complete - Model: {model}, Input tokens: {final_completion.usage.prompt_tokens}, Output tokens: {final_completion.usage.completion_tokens}"
245
381
  )
246
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
+
247
394
  return final_completion
248
395
 
249
396
  # TODO - as per other comment this needs to go in another class. There are a number of "special" cases dealt with
250
397
  # here to deal with OpenRouter idiosyncrasies between e.g. Anthropic and Gemini models.
251
398
  async def _process_stream_manual(self, stream, model: str):
252
399
  """Manual stream processing for providers like Ollama that may not work with ChatCompletionStreamState."""
400
+
253
401
  from openai.types.chat import ChatCompletionMessageToolCall
254
402
 
255
403
  # Track estimated output tokens by counting text chunks
@@ -264,7 +412,9 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
264
412
  usage_data = None
265
413
 
266
414
  # Track tool call state for stream events
267
- tool_call_started = {} # Maps index -> bool for tracking start events
415
+ tool_call_started: dict[int, dict[str, Any]] = {}
416
+ streams_arguments = self._streams_tool_arguments()
417
+ notified_tool_indices: set[int] = set()
268
418
 
269
419
  # Process the stream chunks manually
270
420
  async for chunk in stream:
@@ -279,16 +429,30 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
279
429
  if tool_call.index is not None:
280
430
  index = tool_call.index
281
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
+
282
442
  # Fire "start" event on first chunk for this tool call
283
- if index not in tool_call_started and tool_call.id and tool_call.function and tool_call.function.name:
284
- tool_call_started[index] = True
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
+ }
285
449
  self._notify_tool_stream_listeners(
286
450
  "start",
287
451
  {
288
- "tool_name": tool_call.function.name,
289
- "tool_use_id": tool_call.id,
452
+ "tool_name": function_name,
453
+ "tool_use_id": tool_use_id,
290
454
  "index": index,
291
- "streams_arguments": True, # OpenAI-compatible providers stream arguments
455
+ "streams_arguments": streams_arguments,
292
456
  },
293
457
  )
294
458
  self.logger.info(
@@ -297,22 +461,37 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
297
461
  "progress_action": ProgressAction.CALLING_TOOL,
298
462
  "agent_name": self.name,
299
463
  "model": model,
300
- "tool_name": tool_call.function.name,
301
- "tool_use_id": tool_call.id,
464
+ "tool_name": function_name,
465
+ "tool_use_id": tool_use_id,
302
466
  "tool_event": "start",
467
+ "streams_arguments": streams_arguments,
303
468
  },
304
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
305
476
 
306
477
  # Fire "delta" event for argument chunks
307
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
+ )
308
487
  self._notify_tool_stream_listeners(
309
488
  "delta",
310
489
  {
311
- "tool_name": tool_call.function.name if tool_call.function.name else None,
312
- "tool_use_id": tool_call.id,
490
+ "tool_name": info.get("tool_name"),
491
+ "tool_use_id": info.get("tool_use_id"),
313
492
  "index": index,
314
493
  "chunk": tool_call.function.arguments,
315
- "streams_arguments": True,
494
+ "streams_arguments": info.get("streams_arguments", False),
316
495
  },
317
496
  )
318
497
 
@@ -321,23 +500,27 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
321
500
  content = delta.content
322
501
  accumulated_content += content
323
502
  # Use base class method for token estimation and progress emission
324
- estimated_tokens = self._update_streaming_progress(content, model, estimated_tokens)
503
+ estimated_tokens = self._update_streaming_progress(
504
+ content, model, estimated_tokens
505
+ )
325
506
  self._notify_tool_stream_listeners(
326
507
  "text",
327
508
  {
328
509
  "chunk": content,
329
- "streams_arguments": True,
510
+ "streams_arguments": streams_arguments,
330
511
  },
331
512
  )
332
513
 
333
514
  # Fire "stop" event when tool calls complete
334
515
  if choice.finish_reason == "tool_calls":
335
- for index in tool_call_started.keys():
516
+ for index, info in list(tool_call_started.items()):
336
517
  self._notify_tool_stream_listeners(
337
518
  "stop",
338
519
  {
520
+ "tool_name": info.get("tool_name"),
521
+ "tool_use_id": info.get("tool_use_id"),
339
522
  "index": index,
340
- "streams_arguments": True,
523
+ "streams_arguments": info.get("streams_arguments", False),
341
524
  },
342
525
  )
343
526
  self.logger.info(
@@ -346,9 +529,14 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
346
529
  "progress_action": ProgressAction.CALLING_TOOL,
347
530
  "agent_name": self.name,
348
531
  "model": model,
532
+ "tool_name": info.get("tool_name"),
533
+ "tool_use_id": info.get("tool_use_id"),
349
534
  "tool_event": "stop",
535
+ "streams_arguments": info.get("streams_arguments", False),
350
536
  },
351
537
  )
538
+ notified_tool_indices.add(index)
539
+ tool_call_started.clear()
352
540
 
353
541
  # Extract other fields from the chunk
354
542
  if chunk.choices:
@@ -449,6 +637,15 @@ class OpenAILLM(FastAgentLLM[ChatCompletionMessageParam, ChatCompletionMessage])
449
637
  f"Streaming complete - Model: {model}, Input tokens: {getattr(usage_data, 'prompt_tokens', 0)}, Output tokens: {actual_tokens}"
450
638
  )
451
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
+
452
649
  return final_completion
453
650
 
454
651
  async def _openai_completion(
@@ -106,7 +106,6 @@ class SkillRegistry:
106
106
  errors: List[dict[str, str]] | None = None,
107
107
  ) -> List[SkillManifest]:
108
108
  manifests: List[SkillManifest] = []
109
- cwd = Path.cwd()
110
109
  for entry in sorted(directory.iterdir()):
111
110
  if not entry.is_dir():
112
111
  continue
@@ -115,13 +114,22 @@ class SkillRegistry:
115
114
  continue
116
115
  manifest, error = cls._parse_manifest(manifest_path)
117
116
  if manifest:
118
- relative_path: Path | None = None
119
- for base in (cwd, directory):
120
- try:
121
- relative_path = manifest_path.relative_to(base)
122
- break
123
- except ValueError:
124
- continue
117
+ # Compute relative path from skills directory (not cwd)
118
+ # Old behavior: try both cwd and directory
119
+ # relative_path: Path | None = None
120
+ # for base in (cwd, directory):
121
+ # try:
122
+ # relative_path = manifest_path.relative_to(base)
123
+ # break
124
+ # except ValueError:
125
+ # continue
126
+
127
+ # New behavior: always relative to skills directory
128
+ try:
129
+ relative_path = manifest_path.relative_to(directory)
130
+ except ValueError:
131
+ relative_path = None
132
+
125
133
  manifest = replace(manifest, relative_path=relative_path)
126
134
  manifests.append(manifest)
127
135
  elif errors is not None:
@@ -175,7 +183,7 @@ def format_skills_for_prompt(manifests: Sequence[SkillManifest]) -> str:
175
183
  preamble = (
176
184
  "Skills provide specialized capabilities and domain knowledge. Use a Skill if it seems in any way "
177
185
  "relevant to the Users task, intent or would increase effectiveness. \n"
178
- "The 'execute' tool gives you shell access to the current working directory (agent workspace) "
186
+ "The 'execute' tool gives you direct shell access to the current working directory (agent workspace) "
179
187
  "and outputted files are visible to the User.\n"
180
188
  "To use a Skill you must first read the SKILL.md file (use 'execute' tool).\n "
181
189
  "Only use skills listed in <available_skills> below.\n\n"
@@ -42,13 +42,13 @@ class ShellRuntime:
42
42
 
43
43
  self._tool = Tool(
44
44
  name="execute",
45
- description=f"Run a shell command ({shell_name}) inside the agent workspace and return its output.",
45
+ description=f"Run a shell command directly in {shell_name}.",
46
46
  inputSchema={
47
47
  "type": "object",
48
48
  "properties": {
49
49
  "command": {
50
50
  "type": "string",
51
- "description": "Shell command to execute (e.g. 'cat README.md').",
51
+ "description": "Command string only - no shell executable prefix (correct: 'pwd', incorrect: 'bash -c pwd').",
52
52
  }
53
53
  },
54
54
  "required": ["command"],
@@ -71,8 +71,8 @@ class ShellRuntime:
71
71
  def working_directory(self) -> Path:
72
72
  """Return the working directory used for shell execution."""
73
73
  # TODO -- reinstate when we provide duplication/isolation of skill workspaces
74
- # if self._skills_directory and self._skills_directory.exists():
75
- # return self._skills_directory
74
+ if self._skills_directory and self._skills_directory.exists():
75
+ return self._skills_directory
76
76
  return Path.cwd()
77
77
 
78
78
  def runtime_info(self) -> Dict[str, str | None]: