mbxai 2.2.0__py3-none-any.whl → 2.3.1__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.
- mbxai/__init__.py +23 -2
- mbxai/agent/__init__.py +13 -2
- mbxai/agent/client.py +840 -629
- mbxai/agent/client_legacy.py +804 -0
- mbxai/agent/models.py +264 -31
- mbxai/examples/enhanced_agent_example.py +344 -0
- mbxai/examples/redis_session_handler_example.py +248 -0
- mbxai/mcp/server.py +1 -1
- mbxai-2.3.1.dist-info/METADATA +1191 -0
- {mbxai-2.2.0.dist-info → mbxai-2.3.1.dist-info}/RECORD +12 -9
- mbxai-2.2.0.dist-info/METADATA +0 -492
- {mbxai-2.2.0.dist-info → mbxai-2.3.1.dist-info}/WHEEL +0 -0
- {mbxai-2.2.0.dist-info → mbxai-2.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,804 @@
|
|
1
|
+
"""
|
2
|
+
Agent client implementation for MBX AI.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from typing import Any, Union, Type, Callable
|
6
|
+
import logging
|
7
|
+
import json
|
8
|
+
from pydantic import BaseModel
|
9
|
+
|
10
|
+
from ..openrouter import OpenRouterClient
|
11
|
+
from ..tools import ToolClient
|
12
|
+
from ..mcp import MCPClient
|
13
|
+
from .models import AgentResponse, Question, QuestionList, AnswerList, Result, QualityCheck, TokenUsage, TokenSummary
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class AgentClient:
|
19
|
+
"""
|
20
|
+
Agent client that wraps other AI clients with a dialog-based thinking process.
|
21
|
+
|
22
|
+
The agent follows a multi-step process:
|
23
|
+
1. Analyze the prompt and generate clarifying questions (if ask_questions=True)
|
24
|
+
2. Wait for user answers or auto-answer questions
|
25
|
+
3. Process the prompt with available information
|
26
|
+
4. Quality check the result and iterate if needed
|
27
|
+
5. Generate final response in the requested format
|
28
|
+
|
29
|
+
Requirements:
|
30
|
+
- The wrapped AI client MUST have a 'parse' method for structured responses
|
31
|
+
- All AI interactions use structured Pydantic models for reliable parsing
|
32
|
+
- Supports OpenRouterClient, ToolClient, and MCPClient (all have parse methods)
|
33
|
+
|
34
|
+
Tool Registration:
|
35
|
+
- Provides proxy methods for tool registration when supported by the underlying client
|
36
|
+
- register_tool(): Available with ToolClient and MCPClient
|
37
|
+
- register_mcp_server(): Available with MCPClient only
|
38
|
+
- Throws AttributeError for unsupported clients (e.g., OpenRouterClient)
|
39
|
+
|
40
|
+
Configuration:
|
41
|
+
- max_iterations: Controls how many times the agent will iterate to improve results (default: 2)
|
42
|
+
- Set to 0 to disable quality improvement iterations
|
43
|
+
"""
|
44
|
+
|
45
|
+
def __init__(
|
46
|
+
self,
|
47
|
+
ai_client: Union[OpenRouterClient, ToolClient, MCPClient],
|
48
|
+
max_iterations: int = 2
|
49
|
+
) -> None:
|
50
|
+
"""
|
51
|
+
Initialize the AgentClient.
|
52
|
+
|
53
|
+
Args:
|
54
|
+
ai_client: The underlying AI client (OpenRouterClient, ToolClient, or MCPClient)
|
55
|
+
max_iterations: Maximum number of quality improvement iterations (default: 2)
|
56
|
+
|
57
|
+
Raises:
|
58
|
+
ValueError: If the client doesn't support structured responses (no parse method)
|
59
|
+
"""
|
60
|
+
if not hasattr(ai_client, 'parse'):
|
61
|
+
raise ValueError(
|
62
|
+
f"AgentClient requires a client with structured response support (parse method). "
|
63
|
+
f"The provided client {type(ai_client).__name__} does not have a parse method."
|
64
|
+
)
|
65
|
+
|
66
|
+
if max_iterations < 0:
|
67
|
+
raise ValueError("max_iterations must be non-negative")
|
68
|
+
|
69
|
+
self._ai_client = ai_client
|
70
|
+
self._max_iterations = max_iterations
|
71
|
+
self._agent_sessions: dict[str, dict[str, Any]] = {}
|
72
|
+
|
73
|
+
def register_tool(
|
74
|
+
self,
|
75
|
+
name: str,
|
76
|
+
description: str,
|
77
|
+
function: Callable[..., Any],
|
78
|
+
schema: dict[str, Any] | None = None,
|
79
|
+
) -> None:
|
80
|
+
"""
|
81
|
+
Register a new tool with the underlying AI client.
|
82
|
+
|
83
|
+
This method proxies to the register_tool method of ToolClient or MCPClient.
|
84
|
+
|
85
|
+
Args:
|
86
|
+
name: The name of the tool
|
87
|
+
description: A description of what the tool does
|
88
|
+
function: The function to call when the tool is used
|
89
|
+
schema: The JSON schema for the tool's parameters. If None or empty,
|
90
|
+
will be automatically generated from the function signature.
|
91
|
+
|
92
|
+
Raises:
|
93
|
+
AttributeError: If the underlying client doesn't support tool registration (e.g., OpenRouterClient)
|
94
|
+
"""
|
95
|
+
if hasattr(self._ai_client, 'register_tool'):
|
96
|
+
self._ai_client.register_tool(name, description, function, schema)
|
97
|
+
logger.debug(f"Registered tool '{name}' with {type(self._ai_client).__name__}")
|
98
|
+
else:
|
99
|
+
raise AttributeError(
|
100
|
+
f"Tool registration is not supported by {type(self._ai_client).__name__}. "
|
101
|
+
f"Use ToolClient or MCPClient to register tools."
|
102
|
+
)
|
103
|
+
|
104
|
+
def register_mcp_server(self, name: str, base_url: str) -> None:
|
105
|
+
"""
|
106
|
+
Register an MCP server and load its tools.
|
107
|
+
|
108
|
+
This method proxies to the register_mcp_server method of MCPClient.
|
109
|
+
|
110
|
+
Args:
|
111
|
+
name: The name of the MCP server
|
112
|
+
base_url: The base URL of the MCP server
|
113
|
+
|
114
|
+
Raises:
|
115
|
+
AttributeError: If the underlying client doesn't support MCP server registration (e.g., OpenRouterClient, ToolClient)
|
116
|
+
"""
|
117
|
+
if hasattr(self._ai_client, 'register_mcp_server'):
|
118
|
+
self._ai_client.register_mcp_server(name, base_url)
|
119
|
+
logger.debug(f"Registered MCP server '{name}' at {base_url} with {type(self._ai_client).__name__}")
|
120
|
+
else:
|
121
|
+
raise AttributeError(
|
122
|
+
f"MCP server registration is not supported by {type(self._ai_client).__name__}. "
|
123
|
+
f"Use MCPClient to register MCP servers."
|
124
|
+
)
|
125
|
+
|
126
|
+
def _call_ai_parse(self, messages: list[dict[str, Any]], response_format: Type[BaseModel], conversation_history: list[dict[str, Any]] = None) -> Any:
|
127
|
+
"""Call the parse method on the AI client with optional conversation history."""
|
128
|
+
# Combine conversation history with new messages
|
129
|
+
if conversation_history:
|
130
|
+
full_messages = conversation_history + messages
|
131
|
+
logger.debug(f"🔗 AI call with {len(conversation_history)} history messages + {len(messages)} new messages = {len(full_messages)} total")
|
132
|
+
else:
|
133
|
+
full_messages = messages
|
134
|
+
logger.debug(f"🔗 AI call with {len(messages)} messages (no history)")
|
135
|
+
return self._ai_client.parse(full_messages, response_format)
|
136
|
+
|
137
|
+
def _validate_answers(self, answers: Any) -> bool:
|
138
|
+
"""
|
139
|
+
Validate that answers parameter is a proper AnswerList with content.
|
140
|
+
|
141
|
+
Args:
|
142
|
+
answers: The answers parameter to validate
|
143
|
+
|
144
|
+
Returns:
|
145
|
+
True if answers is valid and has content, False otherwise
|
146
|
+
"""
|
147
|
+
# Check if answers is the correct type
|
148
|
+
if not isinstance(answers, AnswerList):
|
149
|
+
logger.warning(f"Invalid answers type: {type(answers)}. Expected AnswerList, treating as no answers.")
|
150
|
+
return False
|
151
|
+
|
152
|
+
# Check if answers has content
|
153
|
+
if not hasattr(answers, 'answers') or not answers.answers:
|
154
|
+
logger.info(f"Empty answers list provided, proceeding without answers processing.")
|
155
|
+
return False
|
156
|
+
|
157
|
+
# Check if answers list contains valid Answer objects
|
158
|
+
for answer in answers.answers:
|
159
|
+
if not hasattr(answer, 'key') or not hasattr(answer, 'answer'):
|
160
|
+
logger.warning(f"Invalid answer object in list: {answer}. Treating as no answers.")
|
161
|
+
return False
|
162
|
+
|
163
|
+
logger.debug(f"Validated {len(answers.answers)} answers")
|
164
|
+
return True
|
165
|
+
|
166
|
+
def _extract_token_usage(self, response: Any) -> TokenUsage:
|
167
|
+
"""Extract token usage information from an AI response."""
|
168
|
+
try:
|
169
|
+
if hasattr(response, 'usage') and response.usage:
|
170
|
+
usage = response.usage
|
171
|
+
return TokenUsage(
|
172
|
+
prompt_tokens=getattr(usage, 'prompt_tokens', 0),
|
173
|
+
completion_tokens=getattr(usage, 'completion_tokens', 0),
|
174
|
+
total_tokens=getattr(usage, 'total_tokens', 0)
|
175
|
+
)
|
176
|
+
except (AttributeError, TypeError) as e:
|
177
|
+
logger.debug(f"Could not extract token usage: {e}")
|
178
|
+
|
179
|
+
return TokenUsage() # Return empty usage if extraction fails
|
180
|
+
|
181
|
+
def _extract_parsed_content(self, response: Any, response_format: Type[BaseModel]) -> BaseModel:
|
182
|
+
"""Extract the parsed content from the AI response."""
|
183
|
+
if hasattr(response, 'choices') and len(response.choices) > 0:
|
184
|
+
choice = response.choices[0]
|
185
|
+
if hasattr(choice.message, 'parsed') and choice.message.parsed:
|
186
|
+
return choice.message.parsed
|
187
|
+
elif hasattr(choice.message, 'content'):
|
188
|
+
# Try to parse the content as JSON
|
189
|
+
try:
|
190
|
+
content_dict = json.loads(choice.message.content)
|
191
|
+
return response_format(**content_dict)
|
192
|
+
except (json.JSONDecodeError, TypeError):
|
193
|
+
# If parsing fails, create a default response
|
194
|
+
if response_format == QuestionList:
|
195
|
+
return QuestionList(questions=[])
|
196
|
+
elif response_format == Result:
|
197
|
+
return Result(result=choice.message.content)
|
198
|
+
elif response_format == QualityCheck:
|
199
|
+
return QualityCheck(is_good=True, feedback="")
|
200
|
+
else:
|
201
|
+
# For other formats, try to create with content
|
202
|
+
return response_format(result=choice.message.content)
|
203
|
+
|
204
|
+
# Fallback - create empty/default response
|
205
|
+
if response_format == QuestionList:
|
206
|
+
return QuestionList(questions=[])
|
207
|
+
elif response_format == Result:
|
208
|
+
return Result(result="No response generated")
|
209
|
+
elif response_format == QualityCheck:
|
210
|
+
return QualityCheck(is_good=True, feedback="")
|
211
|
+
else:
|
212
|
+
return response_format()
|
213
|
+
|
214
|
+
def agent(
|
215
|
+
self,
|
216
|
+
prompt: str = None,
|
217
|
+
final_response_structure: Type[BaseModel] = None,
|
218
|
+
ask_questions: bool = True,
|
219
|
+
agent_id: str = None,
|
220
|
+
answers: AnswerList | None = None
|
221
|
+
) -> AgentResponse:
|
222
|
+
"""
|
223
|
+
Process a prompt through the agent's thinking process.
|
224
|
+
|
225
|
+
Args:
|
226
|
+
prompt: The prompt from the user (optional if agent_id exists with history)
|
227
|
+
final_response_structure: Pydantic model defining the expected final response format (required for new sessions)
|
228
|
+
ask_questions: Whether to ask clarifying questions (default: True)
|
229
|
+
agent_id: Optional agent session ID to continue an existing conversation
|
230
|
+
answers: Optional answers to questions (when continuing a conversation with questions)
|
231
|
+
|
232
|
+
Returns:
|
233
|
+
AgentResponse containing either questions to ask or the final response
|
234
|
+
|
235
|
+
Raises:
|
236
|
+
ValueError: If neither prompt nor agent_id with history is provided, or if final_response_structure is missing for new sessions
|
237
|
+
"""
|
238
|
+
# Validate inputs and determine session type
|
239
|
+
is_existing_session = agent_id is not None and agent_id in self._agent_sessions
|
240
|
+
existing_session = self._agent_sessions.get(agent_id, {}) if agent_id else {}
|
241
|
+
conversation_history = existing_session.get("conversation_history", []).copy()
|
242
|
+
|
243
|
+
# Validation logic
|
244
|
+
if not is_existing_session:
|
245
|
+
# New session - both prompt and final_response_structure are required
|
246
|
+
if not prompt:
|
247
|
+
raise ValueError("Prompt is required when starting a new agent session")
|
248
|
+
if not final_response_structure:
|
249
|
+
raise ValueError("final_response_structure is required when starting a new agent session")
|
250
|
+
|
251
|
+
# Create new agent_id if not provided
|
252
|
+
if agent_id is None:
|
253
|
+
agent_id = str(__import__("uuid").uuid4())
|
254
|
+
logger.info(f"🚀 Starting new agent process (ID: {agent_id}) with prompt: {prompt[:100]}...")
|
255
|
+
else:
|
256
|
+
# Existing session - use previous final_response_structure if not provided
|
257
|
+
if not final_response_structure:
|
258
|
+
final_response_structure = existing_session.get("final_response_structure")
|
259
|
+
if not final_response_structure:
|
260
|
+
raise ValueError("final_response_structure not found in existing session and not provided")
|
261
|
+
|
262
|
+
# Handle optional prompt for existing sessions
|
263
|
+
if not prompt:
|
264
|
+
# Use conversation history to continue without explicit prompt
|
265
|
+
prompt = "[Continue conversation based on history]"
|
266
|
+
logger.info(f"🔄 Continuing agent process (ID: {agent_id}) without explicit prompt (using history)")
|
267
|
+
else:
|
268
|
+
logger.info(f"🔄 Continuing agent process (ID: {agent_id}) with prompt: {prompt[:100]}...")
|
269
|
+
|
270
|
+
# Initialize token summary
|
271
|
+
token_summary = TokenSummary()
|
272
|
+
|
273
|
+
if conversation_history:
|
274
|
+
logger.info(f"📜 Agent {agent_id}: Loaded conversation history with {len(conversation_history)} messages")
|
275
|
+
|
276
|
+
# Store conversation history for AI calls (don't include current prompt yet)
|
277
|
+
history_for_ai = conversation_history.copy()
|
278
|
+
|
279
|
+
# Add current prompt to full conversation history for session storage
|
280
|
+
conversation_history.append({"role": "user", "content": prompt})
|
281
|
+
|
282
|
+
# Handle answers provided (skip question generation and process directly)
|
283
|
+
if answers is not None:
|
284
|
+
if self._validate_answers(answers):
|
285
|
+
logger.info(f"📝 Agent {agent_id}: Processing with provided answers, skipping question generation")
|
286
|
+
return self._process_answers_directly(agent_id, prompt, final_response_structure, answers, token_summary, history_for_ai)
|
287
|
+
else:
|
288
|
+
logger.info(f"📝 Agent {agent_id}: Invalid or empty answers provided, proceeding with normal flow")
|
289
|
+
|
290
|
+
# Step 1: Generate questions (if ask_questions is True)
|
291
|
+
if ask_questions:
|
292
|
+
logger.info(f"❓ Agent {agent_id}: Analyzing prompt and generating clarifying questions")
|
293
|
+
questions_prompt = f"""
|
294
|
+
Understand this prompt and what the user wants to achieve by it:
|
295
|
+
==========
|
296
|
+
{prompt}
|
297
|
+
==========
|
298
|
+
|
299
|
+
Think about useful steps and which information are required for it. First ask for required information and details to improve that process, when that is useful for the given case. When it's not useful, return an empty list of questions.
|
300
|
+
Use available tools to gather information or perform actions that would improve your response.
|
301
|
+
Analyze the prompt carefully and determine if additional information would significantly improve the quality of the response. Only ask questions that are truly necessary and would materially impact the outcome.
|
302
|
+
|
303
|
+
IMPORTANT: For each question, provide a technical key identifier that:
|
304
|
+
- Uses only alphanumeric characters and underscores
|
305
|
+
- Starts with a letter
|
306
|
+
- Is descriptive but concise (e.g., "user_name", "email_address", "preferred_genre", "budget_range")
|
307
|
+
- Contains no spaces, hyphens, or special characters like ?, !, @, etc.
|
308
|
+
"""
|
309
|
+
|
310
|
+
messages = [{"role": "user", "content": questions_prompt}]
|
311
|
+
|
312
|
+
try:
|
313
|
+
response = self._call_ai_parse(messages, QuestionList, history_for_ai)
|
314
|
+
question_list = self._extract_parsed_content(response, QuestionList)
|
315
|
+
|
316
|
+
# Extract token usage for question generation
|
317
|
+
token_summary.question_generation = self._extract_token_usage(response)
|
318
|
+
|
319
|
+
logger.info(f"❓ Agent {agent_id}: Generated {len(question_list.questions)} questions (tokens: {token_summary.question_generation.total_tokens})")
|
320
|
+
|
321
|
+
# If we have questions, return them to the user
|
322
|
+
if question_list.questions:
|
323
|
+
agent_response = AgentResponse(agent_id=agent_id, questions=question_list.questions, token_summary=token_summary)
|
324
|
+
# Store the session for continuation
|
325
|
+
self._agent_sessions[agent_response.agent_id] = {
|
326
|
+
"original_prompt": prompt,
|
327
|
+
"final_response_structure": final_response_structure,
|
328
|
+
"questions": question_list.questions,
|
329
|
+
"step": "waiting_for_answers",
|
330
|
+
"token_summary": token_summary,
|
331
|
+
"conversation_history": history_for_ai # Include history without current prompt
|
332
|
+
}
|
333
|
+
logger.info(f"📋 Agent {agent_id}: Waiting for user answers to {len(question_list.questions)} questions")
|
334
|
+
return agent_response
|
335
|
+
|
336
|
+
except Exception as e:
|
337
|
+
logger.warning(f"Failed to generate questions: {e}. Proceeding without questions.")
|
338
|
+
|
339
|
+
# Step 2 & 3: No questions or ask_questions=False - proceed directly
|
340
|
+
logger.info(f"⚡ Agent {agent_id}: No questions needed, proceeding directly to processing")
|
341
|
+
return self._process_with_answers(prompt, final_response_structure, [], agent_id, token_summary, history_for_ai)
|
342
|
+
|
343
|
+
def _process_answers_directly(
|
344
|
+
self,
|
345
|
+
agent_id: str,
|
346
|
+
prompt: str,
|
347
|
+
final_response_structure: Type[BaseModel],
|
348
|
+
answers: AnswerList,
|
349
|
+
token_summary: TokenSummary,
|
350
|
+
conversation_history: list[dict[str, Any]]
|
351
|
+
) -> AgentResponse:
|
352
|
+
"""
|
353
|
+
Process answers directly without going through question generation.
|
354
|
+
|
355
|
+
Args:
|
356
|
+
agent_id: The agent session identifier
|
357
|
+
prompt: The current prompt
|
358
|
+
final_response_structure: Expected response structure
|
359
|
+
answers: Provided answers
|
360
|
+
token_summary: Current token usage summary
|
361
|
+
conversation_history: Conversation history
|
362
|
+
|
363
|
+
Returns:
|
364
|
+
AgentResponse with the final result
|
365
|
+
"""
|
366
|
+
# Check if we have a session with questions to match against
|
367
|
+
session = self._agent_sessions.get(agent_id, {})
|
368
|
+
questions = session.get("questions", [])
|
369
|
+
|
370
|
+
if not questions:
|
371
|
+
# No previous questions - treat as simple additional context
|
372
|
+
logger.info(f"📝 Agent {agent_id}: No previous questions found, treating answers as additional context")
|
373
|
+
answer_dict = {answer.key: answer.answer for answer in answers.answers}
|
374
|
+
qa_pairs = []
|
375
|
+
for answer in answers.answers:
|
376
|
+
qa_pairs.append({
|
377
|
+
"question": f"Information about {answer.key}",
|
378
|
+
"key": answer.key,
|
379
|
+
"answer": answer.answer,
|
380
|
+
"required": True
|
381
|
+
})
|
382
|
+
else:
|
383
|
+
# Match answers with previous questions
|
384
|
+
logger.info(f"📝 Agent {agent_id}: Matching {len(answers.answers)} answers with previous questions")
|
385
|
+
answer_dict = {answer.key: answer.answer for answer in answers.answers}
|
386
|
+
|
387
|
+
# Create question-answer pairs for better context
|
388
|
+
qa_pairs = []
|
389
|
+
for question in questions:
|
390
|
+
answer_text = answer_dict.get(question.key, "No answer provided")
|
391
|
+
qa_pairs.append({
|
392
|
+
"question": question.question,
|
393
|
+
"key": question.key,
|
394
|
+
"answer": answer_text,
|
395
|
+
"required": question.required
|
396
|
+
})
|
397
|
+
|
398
|
+
# Process with the provided answers and question context
|
399
|
+
result = self._process_with_answers(
|
400
|
+
prompt,
|
401
|
+
final_response_structure,
|
402
|
+
qa_pairs,
|
403
|
+
agent_id,
|
404
|
+
token_summary,
|
405
|
+
conversation_history
|
406
|
+
)
|
407
|
+
|
408
|
+
# Note: History management is now handled in _process_with_answers
|
409
|
+
# No need to duplicate history management here
|
410
|
+
return result
|
411
|
+
|
412
|
+
def _format_qa_context_for_quality_check(self, answers: Union[list, dict[str, str]]) -> str:
|
413
|
+
"""
|
414
|
+
Format question-answer context for quality check and improvement prompts.
|
415
|
+
|
416
|
+
Args:
|
417
|
+
answers: Question-answer pairs or simple answers
|
418
|
+
|
419
|
+
Returns:
|
420
|
+
Formatted context text
|
421
|
+
"""
|
422
|
+
if not answers:
|
423
|
+
return ""
|
424
|
+
|
425
|
+
if isinstance(answers, list) and answers:
|
426
|
+
# Check if it's a list of question-answer pairs (enhanced format)
|
427
|
+
if isinstance(answers[0], dict) and "question" in answers[0]:
|
428
|
+
context_text = "\nContext Information (Questions & Answers):\n"
|
429
|
+
context_text += "The response was generated with the following additional context:\n\n"
|
430
|
+
for i, qa_pair in enumerate(answers, 1):
|
431
|
+
question = qa_pair.get("question", "Unknown question")
|
432
|
+
answer = qa_pair.get("answer", "No answer provided")
|
433
|
+
required = qa_pair.get("required", True)
|
434
|
+
|
435
|
+
status_marker = "🔴 REQUIRED" if required else "🟡 OPTIONAL"
|
436
|
+
context_text += f"{i}. {status_marker} Q: {question}\n"
|
437
|
+
context_text += f" A: {answer}\n\n"
|
438
|
+
return context_text
|
439
|
+
else:
|
440
|
+
# Legacy format - simple list
|
441
|
+
return f"\nAdditional context: {', '.join(str(a) for a in answers)}\n\n"
|
442
|
+
elif isinstance(answers, dict) and answers:
|
443
|
+
# Legacy format - simple dict
|
444
|
+
context_text = "\nAdditional context provided:\n"
|
445
|
+
for key, answer in answers.items():
|
446
|
+
context_text += f"- {key}: {answer}\n"
|
447
|
+
return context_text + "\n"
|
448
|
+
|
449
|
+
return ""
|
450
|
+
|
451
|
+
def _process_with_answers(
|
452
|
+
self,
|
453
|
+
prompt: str,
|
454
|
+
final_response_structure: Type[BaseModel],
|
455
|
+
answers: Union[list, dict[str, str]],
|
456
|
+
agent_id: str,
|
457
|
+
token_summary: TokenSummary,
|
458
|
+
conversation_history: list[dict[str, Any]] = None
|
459
|
+
) -> AgentResponse:
|
460
|
+
"""
|
461
|
+
Process the prompt with answers through the thinking pipeline.
|
462
|
+
|
463
|
+
Args:
|
464
|
+
prompt: The original prompt
|
465
|
+
final_response_structure: Expected final response structure
|
466
|
+
answers: Question-answer pairs or simple answers (empty if no questions were asked)
|
467
|
+
agent_id: The agent session identifier
|
468
|
+
token_summary: Current token usage summary
|
469
|
+
conversation_history: Optional conversation history for dialog context
|
470
|
+
|
471
|
+
Returns:
|
472
|
+
AgentResponse with the final result
|
473
|
+
"""
|
474
|
+
if conversation_history is None:
|
475
|
+
conversation_history = []
|
476
|
+
|
477
|
+
# Step 3: Process the prompt with thinking
|
478
|
+
logger.info(f"🧠 Agent {agent_id}: Processing prompt and generating initial response")
|
479
|
+
result = self._think_and_process(prompt, answers, agent_id, token_summary, conversation_history)
|
480
|
+
|
481
|
+
# Step 4: Quality check and iteration
|
482
|
+
final_result = self._quality_check_and_iterate(prompt, result, answers, agent_id, token_summary, conversation_history)
|
483
|
+
|
484
|
+
# Step 5: Generate final answer in requested format
|
485
|
+
logger.info(f"📝 Agent {agent_id}: Generating final structured response")
|
486
|
+
final_response = self._generate_final_response(prompt, final_result, final_response_structure, agent_id, token_summary, conversation_history)
|
487
|
+
|
488
|
+
# Update session with the final response in conversation history
|
489
|
+
if agent_id in self._agent_sessions:
|
490
|
+
# Update conversation history with assistant response
|
491
|
+
updated_history = conversation_history.copy()
|
492
|
+
updated_history.append({"role": "assistant", "content": str(final_response)})
|
493
|
+
|
494
|
+
self._agent_sessions[agent_id]["conversation_history"] = updated_history
|
495
|
+
self._agent_sessions[agent_id]["step"] = "completed"
|
496
|
+
self._agent_sessions[agent_id]["token_summary"] = token_summary
|
497
|
+
self._agent_sessions[agent_id]["final_response_structure"] = final_response_structure
|
498
|
+
logger.info(f"💾 Agent {agent_id}: Updated session with conversation history ({len(updated_history)} messages)")
|
499
|
+
else:
|
500
|
+
# Create new session if it doesn't exist
|
501
|
+
updated_history = conversation_history.copy()
|
502
|
+
updated_history.append({"role": "assistant", "content": str(final_response)})
|
503
|
+
|
504
|
+
self._agent_sessions[agent_id] = {
|
505
|
+
"step": "completed",
|
506
|
+
"conversation_history": updated_history,
|
507
|
+
"token_summary": token_summary,
|
508
|
+
"final_response_structure": final_response_structure
|
509
|
+
}
|
510
|
+
logger.info(f"💾 Agent {agent_id}: Created new session with conversation history ({len(updated_history)} messages)")
|
511
|
+
|
512
|
+
# Log final token summary
|
513
|
+
logger.info(f"📊 Agent {agent_id}: Token usage summary - Total: {token_summary.total_tokens} "
|
514
|
+
f"(Prompt: {token_summary.total_prompt_tokens}, Completion: {token_summary.total_completion_tokens})")
|
515
|
+
|
516
|
+
return AgentResponse(agent_id=agent_id, final_response=final_response, token_summary=token_summary)
|
517
|
+
|
518
|
+
def _think_and_process(self, prompt: str, answers: Union[list, dict[str, str]], agent_id: str, token_summary: TokenSummary, conversation_history: list[dict[str, Any]] = None) -> str:
|
519
|
+
"""
|
520
|
+
Process the prompt with thinking.
|
521
|
+
|
522
|
+
Args:
|
523
|
+
prompt: The original prompt
|
524
|
+
answers: Question-answer pairs or simple answers
|
525
|
+
agent_id: The agent session identifier
|
526
|
+
token_summary: Current token usage summary
|
527
|
+
conversation_history: Optional conversation history for dialog context
|
528
|
+
|
529
|
+
Returns:
|
530
|
+
The AI's result
|
531
|
+
"""
|
532
|
+
if conversation_history is None:
|
533
|
+
conversation_history = []
|
534
|
+
# Format answers for the prompt with enhanced context
|
535
|
+
answers_text = ""
|
536
|
+
if isinstance(answers, list) and answers:
|
537
|
+
# Check if it's a list of question-answer pairs (enhanced format)
|
538
|
+
if answers and isinstance(answers[0], dict) and "question" in answers[0]:
|
539
|
+
answers_text = "\n\nQuestion-Answer Context:\n"
|
540
|
+
answers_text += "The following questions were asked to gather more information, along with the answers provided:\n\n"
|
541
|
+
for i, qa_pair in enumerate(answers, 1):
|
542
|
+
question = qa_pair.get("question", "Unknown question")
|
543
|
+
answer = qa_pair.get("answer", "No answer provided")
|
544
|
+
key = qa_pair.get("key", "")
|
545
|
+
required = qa_pair.get("required", True)
|
546
|
+
|
547
|
+
status_marker = "🔴 REQUIRED" if required else "🟡 OPTIONAL"
|
548
|
+
answers_text += f"{i}. {status_marker} Question: {question}\n"
|
549
|
+
answers_text += f" Answer: {answer}\n"
|
550
|
+
if key:
|
551
|
+
answers_text += f" (Key: {key})\n"
|
552
|
+
answers_text += "\n"
|
553
|
+
else:
|
554
|
+
# Legacy format - simple list
|
555
|
+
answers_text = f"\n\nAdditional information: {', '.join(str(a) for a in answers)}\n"
|
556
|
+
elif isinstance(answers, dict) and answers:
|
557
|
+
# Legacy format - simple dict
|
558
|
+
answers_text = "\n\nAdditional information provided:\n"
|
559
|
+
for key, answer in answers.items():
|
560
|
+
answers_text += f"- {key}: {answer}\n"
|
561
|
+
|
562
|
+
thinking_prompt = f"""
|
563
|
+
Think about this prompt, the goal and the steps required to fulfill it:
|
564
|
+
==========
|
565
|
+
{prompt}
|
566
|
+
==========
|
567
|
+
{answers_text}
|
568
|
+
|
569
|
+
Consider the prompt carefully, analyze what the user wants to achieve, and think through the best approach to provide a comprehensive and helpful response.
|
570
|
+
|
571
|
+
IMPORTANT: When formulating your response, take into account both the original prompt AND the specific questions that were asked along with their answers. The questions reveal what additional information was deemed necessary, and the answers provide crucial context that should inform your response.
|
572
|
+
|
573
|
+
Use any available tools to gather information or perform actions that would improve your response.
|
574
|
+
|
575
|
+
Provide your best result for the given prompt, incorporating all the context from the question-answer pairs.
|
576
|
+
"""
|
577
|
+
|
578
|
+
messages = [{"role": "user", "content": thinking_prompt}]
|
579
|
+
|
580
|
+
try:
|
581
|
+
response = self._call_ai_parse(messages, Result, conversation_history)
|
582
|
+
result_obj = self._extract_parsed_content(response, Result)
|
583
|
+
|
584
|
+
# Track token usage for thinking process
|
585
|
+
token_summary.thinking_process = self._extract_token_usage(response)
|
586
|
+
logger.info(f"🧠 Agent {agent_id}: Thinking completed (tokens: {token_summary.thinking_process.total_tokens})")
|
587
|
+
|
588
|
+
return result_obj.result
|
589
|
+
except Exception as e:
|
590
|
+
logger.error(f"Error in thinking process: {e}")
|
591
|
+
raise RuntimeError(f"Failed to process prompt with AI client: {e}") from e
|
592
|
+
|
593
|
+
def _quality_check_and_iterate(self, prompt: str, result: str, answers: Union[list, dict[str, str]], agent_id: str, token_summary: TokenSummary, conversation_history: list[dict[str, Any]] = None) -> str:
|
594
|
+
"""
|
595
|
+
Check the quality of the result and iterate if needed.
|
596
|
+
|
597
|
+
Args:
|
598
|
+
prompt: The original prompt
|
599
|
+
result: The current result
|
600
|
+
answers: Question-answer pairs or simple answers
|
601
|
+
agent_id: The agent session identifier
|
602
|
+
token_summary: Current token usage summary
|
603
|
+
conversation_history: Optional conversation history for dialog context
|
604
|
+
|
605
|
+
Returns:
|
606
|
+
The final improved result
|
607
|
+
"""
|
608
|
+
if conversation_history is None:
|
609
|
+
conversation_history = []
|
610
|
+
|
611
|
+
current_result = result
|
612
|
+
|
613
|
+
if self._max_iterations == 0:
|
614
|
+
logger.info(f"✅ Agent {agent_id}: Skipping quality check (max_iterations=0)")
|
615
|
+
return current_result
|
616
|
+
|
617
|
+
logger.info(f"🔍 Agent {agent_id}: Starting quality check and improvement process (max iterations: {self._max_iterations})")
|
618
|
+
|
619
|
+
# Format context information for quality checks
|
620
|
+
context_text = self._format_qa_context_for_quality_check(answers)
|
621
|
+
|
622
|
+
for iteration in range(self._max_iterations):
|
623
|
+
quality_prompt = f"""
|
624
|
+
Given this original prompt:
|
625
|
+
==========
|
626
|
+
{prompt}
|
627
|
+
==========
|
628
|
+
{context_text}
|
629
|
+
And this result:
|
630
|
+
==========
|
631
|
+
{current_result}
|
632
|
+
==========
|
633
|
+
|
634
|
+
Is this result good and comprehensive, or does it need to be improved? Consider if the response fully addresses the prompt, provides sufficient detail, and would be helpful to the user.
|
635
|
+
|
636
|
+
IMPORTANT: Also evaluate whether the result properly incorporates and addresses the information provided through the question-answer pairs above. The response should demonstrate that it has taken this additional context into account.
|
637
|
+
|
638
|
+
Evaluate the quality and provide feedback if improvements are needed.
|
639
|
+
"""
|
640
|
+
|
641
|
+
messages = [{"role": "user", "content": quality_prompt}]
|
642
|
+
|
643
|
+
try:
|
644
|
+
response = self._call_ai_parse(messages, QualityCheck, conversation_history)
|
645
|
+
quality_check = self._extract_parsed_content(response, QualityCheck)
|
646
|
+
|
647
|
+
# Track token usage for quality check
|
648
|
+
quality_check_tokens = self._extract_token_usage(response)
|
649
|
+
token_summary.quality_checks.append(quality_check_tokens)
|
650
|
+
|
651
|
+
if quality_check.is_good:
|
652
|
+
logger.info(f"✅ Agent {agent_id}: Quality check passed on iteration {iteration + 1} (tokens: {quality_check_tokens.total_tokens})")
|
653
|
+
break
|
654
|
+
|
655
|
+
logger.info(f"🔄 Agent {agent_id}: Quality check iteration {iteration + 1} - Improvements needed: {quality_check.feedback[:100]}... (tokens: {quality_check_tokens.total_tokens})")
|
656
|
+
|
657
|
+
# Improve the result
|
658
|
+
improvement_prompt = f"""
|
659
|
+
The original prompt was:
|
660
|
+
==========
|
661
|
+
{prompt}
|
662
|
+
==========
|
663
|
+
{context_text}
|
664
|
+
The current result is:
|
665
|
+
==========
|
666
|
+
{current_result}
|
667
|
+
==========
|
668
|
+
|
669
|
+
Feedback for improvement:
|
670
|
+
==========
|
671
|
+
{quality_check.feedback}
|
672
|
+
==========
|
673
|
+
|
674
|
+
Please provide an improved version that addresses the feedback while maintaining the strengths of the current result. Make sure to incorporate all the context from the question-answer pairs above.
|
675
|
+
"""
|
676
|
+
|
677
|
+
messages = [{"role": "user", "content": improvement_prompt}]
|
678
|
+
improvement_response = self._call_ai_parse(messages, Result, conversation_history)
|
679
|
+
result_obj = self._extract_parsed_content(improvement_response, Result)
|
680
|
+
current_result = result_obj.result
|
681
|
+
|
682
|
+
# Track token usage for improvement
|
683
|
+
improvement_tokens = self._extract_token_usage(improvement_response)
|
684
|
+
token_summary.improvements.append(improvement_tokens)
|
685
|
+
|
686
|
+
logger.info(f"⚡ Agent {agent_id}: Improvement iteration {iteration + 1} completed (tokens: {improvement_tokens.total_tokens})")
|
687
|
+
|
688
|
+
except Exception as e:
|
689
|
+
logger.warning(f"Error in quality check iteration {iteration}: {e}")
|
690
|
+
break
|
691
|
+
|
692
|
+
total_quality_tokens = sum(usage.total_tokens for usage in token_summary.quality_checks)
|
693
|
+
total_improvement_tokens = sum(usage.total_tokens for usage in token_summary.improvements)
|
694
|
+
logger.info(f"🏁 Agent {agent_id}: Quality check completed - {len(token_summary.quality_checks)} checks, {len(token_summary.improvements)} improvements (Quality tokens: {total_quality_tokens}, Improvement tokens: {total_improvement_tokens})")
|
695
|
+
|
696
|
+
return current_result
|
697
|
+
|
698
|
+
def _generate_final_response(self, prompt: str, result: str, final_response_structure: Type[BaseModel], agent_id: str, token_summary: TokenSummary, conversation_history: list[dict[str, Any]] = None) -> BaseModel:
|
699
|
+
"""
|
700
|
+
Generate the final response in the requested format.
|
701
|
+
|
702
|
+
Args:
|
703
|
+
prompt: The original prompt
|
704
|
+
result: The processed result
|
705
|
+
final_response_structure: The expected response structure
|
706
|
+
agent_id: The agent session identifier
|
707
|
+
token_summary: Current token usage summary
|
708
|
+
conversation_history: Optional conversation history for dialog context
|
709
|
+
|
710
|
+
Returns:
|
711
|
+
The final response in the requested format
|
712
|
+
"""
|
713
|
+
if conversation_history is None:
|
714
|
+
conversation_history = []
|
715
|
+
final_prompt = f"""
|
716
|
+
Given this original prompt:
|
717
|
+
==========
|
718
|
+
{prompt}
|
719
|
+
==========
|
720
|
+
|
721
|
+
And this processed result:
|
722
|
+
==========
|
723
|
+
{result}
|
724
|
+
==========
|
725
|
+
|
726
|
+
Generate the final answer in the exact format requested. Make sure the response is well-structured and addresses all aspects of the original prompt.
|
727
|
+
"""
|
728
|
+
|
729
|
+
messages = [{"role": "user", "content": final_prompt}]
|
730
|
+
|
731
|
+
try:
|
732
|
+
response = self._call_ai_parse(messages, final_response_structure, conversation_history)
|
733
|
+
final_response = self._extract_parsed_content(response, final_response_structure)
|
734
|
+
|
735
|
+
# Track token usage for final response generation
|
736
|
+
token_summary.final_response = self._extract_token_usage(response)
|
737
|
+
logger.info(f"📝 Agent {agent_id}: Final structured response generated (tokens: {token_summary.final_response.total_tokens})")
|
738
|
+
|
739
|
+
return final_response
|
740
|
+
except Exception as e:
|
741
|
+
logger.error(f"Error generating final response: {e}")
|
742
|
+
# Fallback - try to create a basic response
|
743
|
+
try:
|
744
|
+
# If the structure has a 'result' field, use that
|
745
|
+
if hasattr(final_response_structure, 'model_fields') and 'result' in final_response_structure.model_fields:
|
746
|
+
return final_response_structure(result=result)
|
747
|
+
else:
|
748
|
+
# Try to create with the first field
|
749
|
+
fields = final_response_structure.model_fields
|
750
|
+
if fields:
|
751
|
+
first_field = next(iter(fields.keys()))
|
752
|
+
return final_response_structure(**{first_field: result})
|
753
|
+
else:
|
754
|
+
return final_response_structure()
|
755
|
+
except Exception as fallback_error:
|
756
|
+
logger.error(f"Fallback response creation failed: {fallback_error}")
|
757
|
+
# Last resort - return the structure with default values
|
758
|
+
return final_response_structure()
|
759
|
+
|
760
|
+
def get_session_info(self, agent_id: str) -> dict[str, Any]:
|
761
|
+
"""
|
762
|
+
Get information about an agent session.
|
763
|
+
|
764
|
+
Args:
|
765
|
+
agent_id: The agent session identifier
|
766
|
+
|
767
|
+
Returns:
|
768
|
+
Session information dictionary
|
769
|
+
|
770
|
+
Raises:
|
771
|
+
ValueError: If the agent session is not found
|
772
|
+
"""
|
773
|
+
if agent_id not in self._agent_sessions:
|
774
|
+
raise ValueError(f"Agent session {agent_id} not found")
|
775
|
+
|
776
|
+
session = self._agent_sessions[agent_id].copy()
|
777
|
+
# Remove sensitive information and add summary
|
778
|
+
session["conversation_length"] = len(session.get("conversation_history", []))
|
779
|
+
return session
|
780
|
+
|
781
|
+
def delete_session(self, agent_id: str) -> bool:
|
782
|
+
"""
|
783
|
+
Delete an agent session.
|
784
|
+
|
785
|
+
Args:
|
786
|
+
agent_id: The agent session identifier
|
787
|
+
|
788
|
+
Returns:
|
789
|
+
True if session was deleted, False if it didn't exist
|
790
|
+
"""
|
791
|
+
if agent_id in self._agent_sessions:
|
792
|
+
del self._agent_sessions[agent_id]
|
793
|
+
logger.info(f"🗑️ Deleted agent session {agent_id}")
|
794
|
+
return True
|
795
|
+
return False
|
796
|
+
|
797
|
+
def list_sessions(self) -> list[str]:
|
798
|
+
"""
|
799
|
+
List all active agent session IDs.
|
800
|
+
|
801
|
+
Returns:
|
802
|
+
List of agent session IDs
|
803
|
+
"""
|
804
|
+
return list(self._agent_sessions.keys())
|