solana-agent 27.5.0__py3-none-any.whl → 28.1.0__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.
@@ -10,6 +10,7 @@ import datetime as main_datetime
10
10
  from datetime import datetime
11
11
  import json
12
12
  import logging # Add logging
13
+ import re
13
14
  from typing import AsyncGenerator, Dict, List, Literal, Optional, Any, Union
14
15
 
15
16
  from solana_agent.interfaces.services.agent import AgentService as AgentServiceInterface
@@ -204,6 +205,137 @@ class AgentService(AgentServiceInterface):
204
205
  )
205
206
  return {"status": "error", "message": f"Error executing tool: {str(e)}"}
206
207
 
208
+ # --- Helper function to recursively substitute placeholders ---
209
+ def _substitute_placeholders(self, data: Any, results_map: Dict[str, str]) -> Any:
210
+ """Recursively substitutes placeholders like {{tool_name.result}} or {output_of_tool_name} in strings."""
211
+ if isinstance(data, str):
212
+ # Regex to find placeholders like {{tool_name.result}} or {output_of_tool_name}
213
+ placeholder_pattern = re.compile(
214
+ r"\{\{(?P<name1>[a-zA-Z0-9_]+)\.result\}\}|\{output_of_(?P<name2>[a-zA-Z0-9_]+)\}"
215
+ )
216
+
217
+ def replace_match(match):
218
+ tool_name = match.group("name1") or match.group("name2")
219
+ if tool_name and tool_name in results_map:
220
+ logger.debug(f"Substituting placeholder for '{tool_name}'")
221
+ return results_map[tool_name]
222
+ else:
223
+ # If placeholder not found, leave it as is but log warning
224
+ logger.warning(
225
+ f"Could not find result for placeholder tool '{tool_name}'. Leaving placeholder."
226
+ )
227
+ return match.group(0) # Return original placeholder
228
+
229
+ # Use re.sub with the replacement function
230
+ return placeholder_pattern.sub(replace_match, data)
231
+ elif isinstance(data, dict):
232
+ # Recursively process dictionary values
233
+ return {
234
+ k: self._substitute_placeholders(v, results_map)
235
+ for k, v in data.items()
236
+ }
237
+ elif isinstance(data, list):
238
+ # Recursively process list items
239
+ return [self._substitute_placeholders(item, results_map) for item in data]
240
+ else:
241
+ # Return non-string/dict/list types as is
242
+ return data
243
+
244
+ # --- Helper to parse tool calls ---
245
+ def _parse_tool_calls(self, text: str) -> List[Dict[str, Any]]:
246
+ """Parses all [TOOL]...[/TOOL] blocks in the text."""
247
+ tool_calls = []
248
+ # Regex to find all tool blocks, non-greedy match for content
249
+ pattern = re.compile(r"\[TOOL\](.*?)\[/TOOL\]", re.DOTALL | re.IGNORECASE)
250
+ matches = pattern.finditer(text)
251
+
252
+ for match in matches:
253
+ tool_content = match.group(1).strip()
254
+ tool_name = None
255
+ parameters = {}
256
+ try:
257
+ for line in tool_content.split("\n"):
258
+ line = line.strip()
259
+ if not line:
260
+ continue
261
+ if line.lower().startswith("name:"):
262
+ tool_name = line[5:].strip()
263
+ elif line.lower().startswith("parameters:"):
264
+ params_text = line[11:].strip()
265
+ try:
266
+ # Prefer JSON parsing
267
+ parameters = json.loads(params_text)
268
+ except json.JSONDecodeError:
269
+ logger.warning(
270
+ f"Failed to parse parameters as JSON, falling back: {params_text}"
271
+ )
272
+ # Fallback: Treat as simple key=value (less robust)
273
+ try:
274
+ # Basic eval might work for {"key": "value"} but is risky
275
+ # parameters = eval(params_text) # Avoid eval if possible
276
+ # Safer fallback: Assume simple string if not JSON-like
277
+ if not params_text.startswith("{"):
278
+ # Try splitting key=value pairs? Very brittle.
279
+ # For now, log warning and skip complex fallback parsing
280
+ logger.error(
281
+ f"Cannot parse non-JSON parameters reliably: {params_text}"
282
+ )
283
+ parameters = {
284
+ "_raw_params": params_text
285
+ } # Store raw string
286
+ else:
287
+ # If it looks like a dict but isn't valid JSON, log error
288
+ logger.error(
289
+ f"Invalid dictionary format for parameters: {params_text}"
290
+ )
291
+ parameters = {"_raw_params": params_text}
292
+
293
+ except Exception as parse_err:
294
+ logger.error(
295
+ f"Fallback parameter parsing failed: {parse_err}"
296
+ )
297
+ parameters = {
298
+ "_raw_params": params_text
299
+ } # Store raw string on error
300
+
301
+ if tool_name:
302
+ tool_calls.append({"name": tool_name, "parameters": parameters})
303
+ else:
304
+ logger.warning(f"Parsed tool block missing name: {tool_content}")
305
+ except Exception as e:
306
+ logger.error(f"Error parsing tool content: {tool_content} - {e}")
307
+
308
+ logger.info(f"Parsed {len(tool_calls)} tool calls from response.")
309
+ return tool_calls
310
+
311
+ # --- Helper to execute a single parsed tool call ---
312
+ async def _execute_single_tool(
313
+ self, agent_name: str, tool_call: Dict[str, Any]
314
+ ) -> Dict[str, Any]:
315
+ """Executes a single tool call dictionary and returns its result."""
316
+ tool_name = tool_call.get("name")
317
+ parameters = tool_call.get("parameters", {})
318
+ if not tool_name:
319
+ return {
320
+ "tool_name": "unknown",
321
+ "status": "error",
322
+ "message": "Tool name missing in parsed call",
323
+ }
324
+ # Ensure parameters is a dict, even if parsing failed
325
+ if not isinstance(parameters, dict):
326
+ logger.warning(
327
+ f"Parameters for tool '{tool_name}' is not a dict: {parameters}. Attempting execution with empty params."
328
+ )
329
+ parameters = {}
330
+
331
+ logger.debug(
332
+ f"Preparing to execute tool '{tool_name}' with params: {parameters}"
333
+ )
334
+ result = await self.execute_tool(agent_name, tool_name, parameters)
335
+ # Add tool name to result for easier aggregation
336
+ result["tool_name"] = tool_name
337
+ return result
338
+
207
339
  async def generate_response(
208
340
  self,
209
341
  agent_name: str,
@@ -229,18 +361,17 @@ class AgentService(AgentServiceInterface):
229
361
  ] = "aac",
230
362
  prompt: Optional[str] = None,
231
363
  ) -> AsyncGenerator[Union[str, bytes], None]: # pragma: no cover
232
- """Generate a response with support for text/audio input/output and guardrails.
364
+ """Generate a response, supporting multiple sequential tool calls with placeholder substitution.
233
365
 
234
- If output_format is 'text' and output_guardrails are present, the response
235
- will be buffered entirely before applying guardrails and yielding a single result.
236
- Otherwise, text responses stream chunk-by-chunk. Audio responses always buffer.
366
+ Text responses are always generated as a single block.
367
+ Audio responses always buffer text before TTS.
237
368
  """
238
369
  agent = next((a for a in self.agents if a.name == agent_name), None)
239
370
  if not agent:
240
371
  error_msg = f"Agent '{agent_name}' not found."
241
372
  logger.warning(error_msg)
242
- # Handle error output (unchanged)
243
373
  if output_format == "audio":
374
+ # Assuming tts returns an async generator
244
375
  async for chunk in self.llm_provider.tts(
245
376
  error_msg,
246
377
  instructions=audio_instructions,
@@ -249,367 +380,307 @@ class AgentService(AgentServiceInterface):
249
380
  ):
250
381
  yield chunk
251
382
  else:
252
- yield error_msg
383
+ yield error_msg # Yield the single error string
253
384
  return
254
385
 
255
- # --- Determine Buffering Strategy ---
256
- # Buffer text ONLY if format is text AND guardrails are present
257
- should_buffer_text = bool(self.output_guardrails) and output_format == "text"
258
386
  logger.debug(
259
- f"Text buffering strategy: {'Buffer full response' if should_buffer_text else 'Stream chunks'}"
387
+ f"Generating response for agent '{agent_name}'. Output format: {output_format}."
260
388
  )
261
389
 
262
390
  try:
263
391
  # --- System Prompt Assembly ---
264
392
  system_prompt_parts = [self.get_agent_system_prompt(agent_name)]
265
-
266
- # Add tool usage instructions if tools are available for the agent
267
393
  tool_instructions = self._get_tool_usage_prompt(agent_name)
268
394
  if tool_instructions:
269
395
  system_prompt_parts.append(tool_instructions)
270
-
271
- # Add user ID context
272
396
  system_prompt_parts.append(f"USER IDENTIFIER: {user_id}")
273
-
274
- # Add memory context if provided
275
397
  if memory_context:
276
398
  system_prompt_parts.append(f"\nCONVERSATION HISTORY:\n{memory_context}")
277
-
278
- # Add optional prompt if provided
279
399
  if prompt:
280
400
  system_prompt_parts.append(f"\nADDITIONAL PROMPT:\n{prompt}")
401
+ final_system_prompt = "\n\n".join(filter(None, system_prompt_parts))
281
402
 
282
- final_system_prompt = "\n\n".join(
283
- filter(None, system_prompt_parts)
284
- ) # Join non-empty parts
285
- # --- End System Prompt Assembly ---
286
-
287
- # --- Response Generation ---
288
- complete_text_response = (
289
- "" # Always used for final storage and potentially for buffering
290
- )
291
- full_response_buffer = "" # Used ONLY for audio buffering
292
-
293
- # Tool call handling variables (unchanged)
294
- tool_buffer = ""
295
- pending_chunk = ""
296
- is_tool_call = False
403
+ # --- Initial Response Generation (No Streaming) ---
404
+ initial_llm_response_buffer = ""
405
+ tool_calls_detected = False
297
406
  start_marker = "[TOOL]"
298
- end_marker = "[/TOOL]"
299
407
 
300
- logger.info(
301
- f"Generating response for agent '{agent_name}' with query length {len(str(query))}"
302
- )
303
- async for chunk in self.llm_provider.generate_text(
408
+ logger.info(f"Generating initial response for agent '{agent_name}'...")
409
+ # Call generate_text and await the string result
410
+ initial_llm_response_buffer = await self.llm_provider.generate_text(
304
411
  prompt=str(query),
305
412
  system_prompt=final_system_prompt,
306
413
  api_key=self.api_key,
307
414
  base_url=self.base_url,
308
415
  model=self.model,
416
+ )
417
+
418
+ # Check for errors returned as string by the adapter
419
+ if isinstance(
420
+ initial_llm_response_buffer, str
421
+ ) and initial_llm_response_buffer.startswith(
422
+ "I apologize, but I encountered an error"
309
423
  ):
310
- # --- Chunk Processing & Tool Call Logic (Modified Yielding) ---
311
- if pending_chunk:
312
- combined_chunk = pending_chunk + chunk
313
- pending_chunk = ""
424
+ logger.error(
425
+ f"LLM provider failed during initial generation: {initial_llm_response_buffer}"
426
+ )
427
+ # Yield the error and exit
428
+ if output_format == "audio":
429
+ async for chunk in self.llm_provider.tts(
430
+ initial_llm_response_buffer,
431
+ voice=audio_voice,
432
+ response_format=audio_output_format,
433
+ instructions=audio_instructions,
434
+ ):
435
+ yield chunk
314
436
  else:
315
- combined_chunk = chunk
316
-
317
- # STEP 1: Check for tool call start marker
318
- if start_marker in combined_chunk and not is_tool_call:
319
- is_tool_call = True
320
- start_pos = combined_chunk.find(start_marker)
321
- before_marker = combined_chunk[:start_pos]
322
- after_marker = combined_chunk[start_pos:]
323
-
324
- if before_marker:
325
- processed_before_marker = before_marker
326
- # Apply guardrails ONLY if NOT buffering text
327
- if not should_buffer_text:
328
- for guardrail in self.output_guardrails:
329
- try:
330
- processed_before_marker = await guardrail.process(
331
- processed_before_marker
332
- )
333
- except Exception as e:
334
- logger.error(
335
- f"Error applying output guardrail {guardrail.__class__.__name__} to pre-tool text: {e}"
336
- )
437
+ yield initial_llm_response_buffer
438
+ return
337
439
 
338
- # Yield ONLY if NOT buffering text
339
- if (
340
- processed_before_marker
341
- and not should_buffer_text
342
- and output_format == "text"
343
- ):
344
- yield processed_before_marker
440
+ # Check for tool markers in the complete response
441
+ if start_marker.lower() in initial_llm_response_buffer.lower():
442
+ tool_calls_detected = True
443
+ logger.info("Tool call marker detected in initial response.")
345
444
 
346
- # Always accumulate for final response / audio buffer
347
- if processed_before_marker:
348
- complete_text_response += processed_before_marker
349
- if output_format == "audio":
350
- full_response_buffer += processed_before_marker
445
+ logger.debug(
446
+ f"Full initial LLM response buffer:\n--- START ---\n{initial_llm_response_buffer}\n--- END ---"
447
+ )
448
+ logger.info(
449
+ f"Initial LLM response received (length: {len(initial_llm_response_buffer)}). Tools detected: {tool_calls_detected}"
450
+ )
351
451
 
352
- tool_buffer = after_marker
353
- continue
452
+ # --- Tool Execution Phase (if tools were detected) ---
453
+ final_response_text = ""
454
+ if tool_calls_detected:
455
+ parsed_calls = self._parse_tool_calls(initial_llm_response_buffer)
354
456
 
355
- # STEP 2: Handle ongoing tool call collection
356
- if is_tool_call:
357
- tool_buffer += combined_chunk
358
- if end_marker in tool_buffer:
359
- response_text = await self._handle_tool_call(
360
- agent_name=agent_name, tool_text=tool_buffer
361
- )
362
- response_text = self._clean_tool_response(response_text)
363
- user_prompt = f"{str(query)}\n\nTOOL RESULT: {response_text}"
364
-
365
- # --- Rebuild system prompt for follow-up ---
366
- follow_up_system_prompt_parts = [
367
- self.get_agent_system_prompt(agent_name)
368
- ]
369
- # Re-add tool instructions if needed for follow-up context
370
- if tool_instructions:
371
- follow_up_system_prompt_parts.append(tool_instructions)
372
- follow_up_system_prompt_parts.append(
373
- f"USER IDENTIFIER: {user_id}"
374
- )
375
- # Include original memory + original query + tool result context
376
- if memory_context:
377
- follow_up_system_prompt_parts.append(
378
- f"\nORIGINAL CONVERSATION HISTORY:\n{memory_context}"
379
- )
380
- # Add the original prompt if it was provided
381
- if prompt:
382
- follow_up_system_prompt_parts.append(
383
- f"\nORIGINAL ADDITIONAL PROMPT:\n{prompt}"
384
- )
385
- # Add context about the tool call that just happened
386
- follow_up_system_prompt_parts.append(
387
- f"\nPREVIOUS TOOL CALL CONTEXT:\nOriginal Query: {str(query)}\nTool Used: (Inferred from result)\nTool Result: {response_text}"
388
- )
457
+ if parsed_calls:
458
+ # --- Execute tools SEQUENTIALLY with Placeholder Substitution ---
459
+ executed_tool_results = [] # Store full result dicts
460
+ # Map tool names to their string results for substitution
461
+ tool_results_map: Dict[str, str] = {}
389
462
 
390
- final_follow_up_system_prompt = "\n\n".join(
391
- filter(None, follow_up_system_prompt_parts)
463
+ logger.info(
464
+ f"Executing {len(parsed_calls)} tools sequentially with substitution..."
465
+ )
466
+ for i, call in enumerate(parsed_calls):
467
+ tool_name_to_exec = call.get("name", "unknown")
468
+ logger.info(
469
+ f"Executing tool {i + 1}/{len(parsed_calls)}: {tool_name_to_exec}"
392
470
  )
393
- # --- End Rebuild system prompt ---
394
-
395
- logger.info("Generating follow-up response with tool results")
396
- async for processed_chunk in self.llm_provider.generate_text(
397
- prompt=user_prompt, # Use the prompt that includes the tool result
398
- system_prompt=final_follow_up_system_prompt,
399
- api_key=self.api_key,
400
- base_url=self.base_url,
401
- model=self.model,
402
- ):
403
- chunk_to_yield_followup = processed_chunk
404
- # Apply guardrails ONLY if NOT buffering text
405
- if not should_buffer_text:
406
- for guardrail in self.output_guardrails:
407
- try:
408
- chunk_to_yield_followup = (
409
- await guardrail.process(
410
- chunk_to_yield_followup
411
- )
412
- )
413
- except Exception as e:
414
- logger.error(
415
- f"Error applying output guardrail {guardrail.__class__.__name__} to follow-up chunk: {e}"
416
- )
417
-
418
- # Yield ONLY if NOT buffering text
419
- if (
420
- chunk_to_yield_followup
421
- and not should_buffer_text
422
- and output_format == "text"
423
- ):
424
- yield chunk_to_yield_followup
425
-
426
- # Always accumulate
427
- if chunk_to_yield_followup:
428
- complete_text_response += chunk_to_yield_followup
429
- if output_format == "audio":
430
- full_response_buffer += chunk_to_yield_followup
431
-
432
- is_tool_call = False
433
- tool_buffer = ""
434
- pending_chunk = ""
435
- break # Exit the original generation loop
436
-
437
- continue # Continue collecting tool call
438
-
439
- # STEP 3: Check for possible partial start markers
440
- potential_marker = False
441
- chunk_to_yield = combined_chunk
442
- for i in range(1, len(start_marker)):
443
- if combined_chunk.endswith(start_marker[:i]):
444
- pending_chunk = combined_chunk[-i:]
445
- chunk_to_yield = combined_chunk[:-i]
446
- potential_marker = True
447
- break
448
-
449
- if potential_marker:
450
- chunk_to_yield_safe = chunk_to_yield
451
- # Apply guardrails ONLY if NOT buffering text
452
- if not should_buffer_text:
453
- for guardrail in self.output_guardrails:
454
- try:
455
- chunk_to_yield_safe = await guardrail.process(
456
- chunk_to_yield_safe
457
- )
458
- except Exception as e:
459
- logger.error(
460
- f"Error applying output guardrail {guardrail.__class__.__name__} to safe chunk: {e}"
461
- )
462
-
463
- # Yield ONLY if NOT buffering text
464
- if (
465
- chunk_to_yield_safe
466
- and not should_buffer_text
467
- and output_format == "text"
468
- ):
469
- yield chunk_to_yield_safe
470
471
 
471
- # Always accumulate
472
- if chunk_to_yield_safe:
473
- complete_text_response += chunk_to_yield_safe
474
- if output_format == "audio":
475
- full_response_buffer += chunk_to_yield_safe
476
- continue
477
-
478
- # STEP 4: Normal text processing
479
- chunk_to_yield_normal = combined_chunk
480
- # Apply guardrails ONLY if NOT buffering text
481
- if not should_buffer_text:
482
- for guardrail in self.output_guardrails:
472
+ # --- Substitute placeholders in parameters ---
483
473
  try:
484
- chunk_to_yield_normal = await guardrail.process(
485
- chunk_to_yield_normal
474
+ original_params = call.get("parameters", {})
475
+ substituted_params = self._substitute_placeholders(
476
+ original_params, tool_results_map
486
477
  )
487
- except Exception as e:
478
+ if substituted_params != original_params:
479
+ logger.info(
480
+ f"Substituted parameters for tool '{tool_name_to_exec}': {substituted_params}"
481
+ )
482
+ call["parameters"] = substituted_params # Update call dict
483
+ except Exception as sub_err:
488
484
  logger.error(
489
- f"Error applying output guardrail {guardrail.__class__.__name__} to normal chunk: {e}"
485
+ f"Error substituting placeholders for tool '{tool_name_to_exec}': {sub_err}",
486
+ exc_info=True,
490
487
  )
488
+ # Proceed with original params but log the error
491
489
 
492
- # Yield ONLY if NOT buffering text
493
- if (
494
- chunk_to_yield_normal
495
- and not should_buffer_text
496
- and output_format == "text"
497
- ):
498
- yield chunk_to_yield_normal
499
-
500
- # Always accumulate
501
- if chunk_to_yield_normal:
502
- complete_text_response += chunk_to_yield_normal
503
- if output_format == "audio":
504
- full_response_buffer += chunk_to_yield_normal
490
+ # --- Execute the tool ---
491
+ try:
492
+ result = await self._execute_single_tool(agent_name, call)
493
+ executed_tool_results.append(result)
494
+
495
+ # --- Store successful result string for future substitutions ---
496
+ if result.get("status") == "success":
497
+ tool_result_str = str(result.get("result", ""))
498
+ tool_results_map[tool_name_to_exec] = tool_result_str
499
+ logger.debug(
500
+ f"Stored result for '{tool_name_to_exec}' (length: {len(tool_result_str)})"
501
+ )
502
+ else:
503
+ # Store error message as result
504
+ error_message = result.get("message", "Unknown error")
505
+ tool_results_map[tool_name_to_exec] = (
506
+ f"Error: {error_message}"
507
+ )
508
+ logger.warning(
509
+ f"Tool '{tool_name_to_exec}' failed, storing error message as result."
510
+ )
505
511
 
506
- # --- Post-Loop Processing ---
512
+ except Exception as tool_exec_err:
513
+ logger.error(
514
+ f"Exception during execution of tool {tool_name_to_exec}: {tool_exec_err}",
515
+ exc_info=True,
516
+ )
517
+ error_result = {
518
+ "tool_name": tool_name_to_exec,
519
+ "status": "error",
520
+ "message": f"Exception during execution: {str(tool_exec_err)}",
521
+ }
522
+ executed_tool_results.append(error_result)
523
+ tool_results_map[tool_name_to_exec] = (
524
+ f"Error: {str(tool_exec_err)}" # Store error
525
+ )
507
526
 
508
- # Process any incomplete tool call
509
- if is_tool_call and tool_buffer:
510
- logger.warning(
511
- f"Incomplete tool call detected, processing as regular text: {len(tool_buffer)} chars"
512
- )
513
- processed_tool_buffer = tool_buffer
514
- # Apply guardrails ONLY if NOT buffering text
515
- if not should_buffer_text:
516
- for guardrail in self.output_guardrails:
517
- try:
518
- processed_tool_buffer = await guardrail.process(
519
- processed_tool_buffer
527
+ logger.info("Sequential tool execution with substitution complete.")
528
+ # --- End Sequential Execution ---
529
+
530
+ # Format results for the follow-up prompt (use executed_tool_results)
531
+ tool_results_text_parts = []
532
+ for i, result in enumerate(
533
+ executed_tool_results
534
+ ): # Use the collected results
535
+ tool_name = result.get(
536
+ "tool_name", "unknown"
537
+ ) # Name should be in the result dict now
538
+ if (
539
+ isinstance(result, Exception)
540
+ or result.get("status") == "error"
541
+ ):
542
+ error_msg = (
543
+ result.get("message", str(result))
544
+ if isinstance(result, dict)
545
+ else str(result)
520
546
  )
521
- except Exception as e:
522
- logger.error(
523
- f"Error applying output guardrail {guardrail.__class__.__name__} to incomplete tool buffer: {e}"
547
+ logger.error(f"Tool '{tool_name}' failed: {error_msg}")
548
+ tool_results_text_parts.append(
549
+ f"Tool {i + 1} ({tool_name}) Execution Failed:\n{error_msg}"
550
+ )
551
+ else:
552
+ tool_output = str(result.get("result", ""))
553
+ tool_results_text_parts.append(
554
+ f"Tool {i + 1} ({tool_name}) Result:\n{tool_output}"
524
555
  )
556
+ tool_results_context = "\n\n".join(tool_results_text_parts)
525
557
 
526
- # Yield ONLY if NOT buffering text
527
- if (
528
- processed_tool_buffer
529
- and not should_buffer_text
530
- and output_format == "text"
531
- ):
532
- yield processed_tool_buffer
558
+ # --- Generate Final Response using Tool Results (No Streaming) ---
559
+ follow_up_prompt = f"Original Query: {str(query)}\n\nRESULTS FROM TOOL CALLS:\n{tool_results_context}\n\nBased on the original query and the tool results, please provide the final response to the user."
560
+ # Rebuild system prompt
561
+ follow_up_system_prompt_parts = [
562
+ self.get_agent_system_prompt(agent_name)
563
+ ]
564
+ follow_up_system_prompt_parts.append(f"USER IDENTIFIER: {user_id}")
565
+ if memory_context:
566
+ follow_up_system_prompt_parts.append(
567
+ f"\nORIGINAL CONVERSATION HISTORY:\n{memory_context}"
568
+ )
569
+ if prompt:
570
+ follow_up_system_prompt_parts.append(
571
+ f"\nORIGINAL ADDITIONAL PROMPT:\n{prompt}"
572
+ )
573
+ follow_up_system_prompt_parts.append(
574
+ f"\nCONTEXT: You previously decided to run {len(parsed_calls)} tool(s) sequentially to answer the query. The results are provided above."
575
+ )
576
+ final_follow_up_system_prompt = "\n\n".join(
577
+ filter(None, follow_up_system_prompt_parts)
578
+ )
533
579
 
534
- # Always accumulate
535
- if processed_tool_buffer:
536
- complete_text_response += processed_tool_buffer
537
- if output_format == "audio":
538
- full_response_buffer += processed_tool_buffer
580
+ logger.info(
581
+ "Generating final response incorporating tool results..."
582
+ )
583
+ # Call generate_text and await the string result
584
+ synthesized_response_buffer = await self.llm_provider.generate_text(
585
+ prompt=follow_up_prompt,
586
+ system_prompt=final_follow_up_system_prompt,
587
+ api_key=self.api_key,
588
+ base_url=self.base_url,
589
+ model=self.model,
590
+ )
591
+
592
+ # Check for errors returned as string by the adapter
593
+ if isinstance(
594
+ synthesized_response_buffer, str
595
+ ) and synthesized_response_buffer.startswith(
596
+ "I apologize, but I encountered an error"
597
+ ):
598
+ logger.error(
599
+ f"LLM provider failed during final generation: {synthesized_response_buffer}"
600
+ )
601
+ # Yield the error and exit
602
+ if output_format == "audio":
603
+ async for chunk in self.llm_provider.tts(
604
+ synthesized_response_buffer,
605
+ voice=audio_voice,
606
+ response_format=audio_output_format,
607
+ instructions=audio_instructions,
608
+ ):
609
+ yield chunk
610
+ else:
611
+ yield synthesized_response_buffer
612
+ return
613
+
614
+ final_response_text = synthesized_response_buffer
615
+ logger.info(
616
+ f"Final synthesized response length: {len(final_response_text)}"
617
+ )
539
618
 
540
- # --- Final Output Generation ---
619
+ else:
620
+ # Tools detected but parsing failed
621
+ logger.warning(
622
+ "Tool markers detected, but no valid tool calls parsed. Treating initial response as final."
623
+ )
624
+ final_response_text = initial_llm_response_buffer
625
+ else:
626
+ # No tools detected
627
+ final_response_text = initial_llm_response_buffer
628
+ logger.info("No tools detected. Using initial response as final.")
541
629
 
542
- # Case 1: Text output WITH guardrails (apply to buffered response)
543
- if should_buffer_text:
630
+ # --- Final Output Processing (Guardrails, TTS, Yielding) ---
631
+ processed_final_text = final_response_text
632
+ if self.output_guardrails:
544
633
  logger.info(
545
- f"Applying output guardrails to buffered text response (length: {len(complete_text_response)})"
634
+ f"Applying output guardrails to final text response (length: {len(processed_final_text)})"
546
635
  )
547
- processed_full_text = complete_text_response
636
+ original_len = len(processed_final_text)
548
637
  for guardrail in self.output_guardrails:
549
638
  try:
550
- processed_full_text = await guardrail.process(
551
- processed_full_text
639
+ processed_final_text = await guardrail.process(
640
+ processed_final_text
552
641
  )
553
642
  except Exception as e:
554
643
  logger.error(
555
- f"Error applying output guardrail {guardrail.__class__.__name__} to full text buffer: {e}"
644
+ f"Error applying output guardrail {guardrail.__class__.__name__} to final text: {e}"
556
645
  )
557
-
558
- if processed_full_text:
559
- yield processed_full_text
560
- # Update last_text_response with the final processed text
561
- self.last_text_response = processed_full_text
562
-
563
- # Case 2: Audio output (apply guardrails to buffer before TTS) - Unchanged Logic
564
- elif output_format == "audio" and full_response_buffer:
565
- original_buffer = full_response_buffer
566
- processed_audio_buffer = full_response_buffer
567
- for (
568
- guardrail
569
- ) in self.output_guardrails: # Apply even if empty, for consistency
570
- try:
571
- processed_audio_buffer = await guardrail.process(
572
- processed_audio_buffer
573
- )
574
- except Exception as e:
575
- logger.error(
576
- f"Error applying output guardrail {guardrail.__class__.__name__} to audio buffer: {e}"
577
- )
578
- if processed_audio_buffer != original_buffer:
646
+ if len(processed_final_text) != original_len:
579
647
  logger.info(
580
- f"Output guardrails modified audio buffer. Original length: {len(original_buffer)}, New length: {len(processed_audio_buffer)}"
648
+ f"Guardrails modified final text length from {original_len} to {len(processed_final_text)}"
581
649
  )
582
650
 
583
- cleaned_audio_buffer = self._clean_for_audio(processed_audio_buffer)
584
- logger.info(
585
- f"Processing {len(cleaned_audio_buffer)} characters for audio output"
586
- )
587
- async for audio_chunk in self.llm_provider.tts(
588
- text=cleaned_audio_buffer,
589
- voice=audio_voice,
590
- response_format=audio_output_format,
591
- instructions=audio_instructions,
592
- ):
593
- yield audio_chunk
594
- # Update last_text_response with the text *before* TTS cleaning
595
- self.last_text_response = (
596
- processed_audio_buffer # Store the guardrail-processed text
597
- )
651
+ self.last_text_response = processed_final_text
598
652
 
599
- # Case 3: Text output WITHOUT guardrails (already streamed)
600
- elif output_format == "text" and not should_buffer_text:
601
- # Store the complete text response (accumulated from non-processed chunks)
602
- self.last_text_response = complete_text_response
653
+ if output_format == "text":
654
+ # Yield the single final string
655
+ if processed_final_text:
656
+ yield processed_final_text
657
+ else:
658
+ logger.warning("Final processed text was empty.")
659
+ yield ""
660
+ elif output_format == "audio":
661
+ # TTS still needs a generator
662
+ text_for_tts = processed_final_text
663
+ cleaned_audio_buffer = self._clean_for_audio(text_for_tts)
603
664
  logger.info(
604
- "Text streaming complete (no guardrails applied post-stream)."
665
+ f"Processing {len(cleaned_audio_buffer)} characters for audio output"
605
666
  )
667
+ if cleaned_audio_buffer:
668
+ async for audio_chunk in self.llm_provider.tts(
669
+ text=cleaned_audio_buffer,
670
+ voice=audio_voice,
671
+ response_format=audio_output_format,
672
+ instructions=audio_instructions,
673
+ ):
674
+ yield audio_chunk
675
+ else:
676
+ logger.warning("Final text for audio was empty after cleaning.")
606
677
 
607
678
  logger.info(
608
679
  f"Response generation complete for agent '{agent_name}': {len(self.last_text_response)} final chars"
609
680
  )
610
681
 
611
682
  except Exception as e:
612
- # --- Error Handling (unchanged) ---
683
+ # --- Error Handling ---
613
684
  import traceback
614
685
 
615
686
  error_msg = (
@@ -715,9 +786,10 @@ class AgentService(AgentServiceInterface):
715
786
  AVAILABLE TOOLS:
716
787
  {tools_json}
717
788
 
718
- ⚠️ CRITICAL INSTRUCTION: When using a tool, NEVER include explanatory text.
719
- Only output the exact tool call format shown below with NO other text.
720
- Always call the necessary tool to give the latest information.
789
+ ⚠️ CRITICAL INSTRUCTIONS FOR TOOL USAGE:
790
+ 1. EXECUTION ORDER MATTERS: If multiple steps are needed (e.g., get information THEN use it), you MUST output the [TOOL] blocks in the exact sequence they need to run. Output the information-gathering tool call FIRST, then the tool call that uses the information.
791
+ 2. ONLY TOOL CALLS: When using a tool, NEVER include explanatory text before or after the tool call block. Only output the exact tool call format shown below.
792
+ 3. USE TOOLS WHEN NEEDED: Always call the necessary tool to give the latest information, especially for time-sensitive queries.
721
793
 
722
794
  TOOL USAGE FORMAT:
723
795
  [TOOL]
@@ -726,24 +798,62 @@ class AgentService(AgentServiceInterface):
726
798
  [/TOOL]
727
799
 
728
800
  EXAMPLES:
729
- ✅ CORRECT - ONLY the tool call with NOTHING else:
801
+
802
+ ✅ CORRECT - Get news THEN email (Correct Order):
730
803
  [TOOL]
731
804
  name: search_internet
732
- parameters: {{"query": "latest news on Solana"}}
805
+ parameters: {{"query": "latest news on Canada"}}
806
+ [/TOOL]
807
+ [TOOL]
808
+ name: mcp
809
+ parameters: {{"query": "Send an email to
810
+ bob@bob.com with subject
811
+ 'Friendly Reminder to Clean Your Room'
812
+ and body 'Hi Bob, just a friendly
813
+ reminder to please clean your room
814
+ when you get a chance.'"}}
733
815
  [/TOOL]
816
+ (Note: The system will handle replacing placeholders like '{{output_of_search_internet}}' if possible, but the ORDER is crucial.)
734
817
 
735
- ❌ INCORRECT - Never add explanatory text like this:
736
- To get the latest news on Solana, I will search the internet.
818
+
819
+ INCORRECT - Wrong Order:
820
+ [TOOL]
821
+ name: mcp
822
+ parameters: {{"query": "Send an email to
823
+ bob@bob.com with subject
824
+ 'Friendly Reminder to Clean Your Room'
825
+ and body 'Hi Bob, just a friendly
826
+ reminder to please clean your room
827
+ when you get a chance.'"}}
828
+ [/TOOL]
829
+ [TOOL]
830
+ name: search_internet
831
+ parameters: {{"query": "latest news on Canada"}}
832
+ [/TOOL]
833
+
834
+
835
+ ❌ INCORRECT - Explanatory Text:
836
+ To get the news, I'll search.
737
837
  [TOOL]
738
838
  name: search_internet
739
839
  parameters: {{"query": "latest news on Solana"}}
740
840
  [/TOOL]
841
+ Now I will email it.
842
+ [TOOL]
843
+ name: mcp
844
+ parameters: {{"query": "Send an email to
845
+ bob@bob.com with subject
846
+ 'Friendly Reminder to Clean Your Room'
847
+ and body 'Hi Bob, just a friendly
848
+ reminder to please clean your room
849
+ when you get a chance.'"}}
850
+ [/TOOL]
851
+
741
852
 
742
853
  REMEMBER:
743
- 1. Output ONLY the exact tool call format with NO additional text
744
- 2. If the query is time-sensitive (latest news, current status, etc.), ALWAYS use the tool.
745
- 3. After seeing your tool call, I will execute it automatically
746
- 4. You will receive the tool results and can then respond to the user
854
+ - Output ONLY the [TOOL] blocks in the correct execution order.
855
+ - I will execute the tools sequentially as you provide them.
856
+ - You will receive the results of ALL tool calls before formulating the final response.
747
857
  """
748
858
 
749
859
  def _clean_for_audio(self, text: str) -> str: