-
Notifications
You must be signed in to change notification settings - Fork 5
Agent #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Agent #19
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
3c38310
Remove MinIO volumes from version control and add to .gitignore
garland3 ebff30c
Update .gitignore with MinIO volume exclusions
garland3 87f5d81
feat(chat): add file attachment functionality to chat sessions
garland3 fee54a4
addressed bot identified issues
garland3 5dd900c
feat(websocket): implement secure user authentication via reverse proxy
garland3 829377c
reverted changes that were breaking the websocket setup
garland3 db12640
feat: Move files health check endpoint before dynamic route
garland3 332073f
ci: fix SHA tagging for branches and PRs to avoid invalid prefix
garland3 0958210
feat(ci): update Docker image tagging to use GitHub ref variables
garland3 4a6f56a
refactor: simplify logging messages and remove unused code
garland3 edddee1
Potential fix for code scanning alert no. 275: Log Injection
garland3 058abd8
fix(chat): sanitize log message by removing newlines from s3_key and …
garland3 8a41d00
Merge remote-tracking branch 'origin/minio' into minio
garland3 7153a44
use can select agent
garland3 a16e23a
feat: Add custom output filename parameter to json_to_pptx tool
garland3 dbc1735
feat: make pptx tool parameters optional for improved flexibility
garland3 ff84b40
feat(agent): introduce act loop strategy with pure action execution
garland3 1a44a6a
Merge remote-tracking branch 'origin/main' into agent
garland3 48f0634
fix: address PR code review comments
garland3 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from typing import Any, Dict, List, Optional | ||
|
|
||
| from interfaces.llm import LLMProtocol | ||
| from interfaces.tools import ToolManagerProtocol | ||
| from modules.prompts.prompt_provider import PromptProvider | ||
|
|
||
| from .protocols import AgentContext, AgentEvent, AgentEventHandler, AgentLoopProtocol, AgentResult | ||
| from ..utilities import error_utils, tool_utils | ||
|
|
||
|
|
||
| class ActAgentLoop(AgentLoopProtocol): | ||
| """Pure action agent loop - just execute tools in a loop until done. | ||
|
|
||
| No explicit reasoning or observation steps. The LLM directly decides which | ||
| tools to call and when to finish. Fastest strategy with minimal overhead. | ||
|
|
||
| Exit conditions: | ||
| - LLM calls the "finished" tool with a final_answer | ||
| - No tool calls returned (LLM provides text response) | ||
| - Max steps reached | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| *, | ||
| llm: LLMProtocol, | ||
| tool_manager: Optional[ToolManagerProtocol], | ||
| prompt_provider: Optional[PromptProvider], | ||
| connection: Any = None, | ||
| ) -> None: | ||
| self.llm = llm | ||
| self.tool_manager = tool_manager | ||
| self.prompt_provider = prompt_provider | ||
| self.connection = connection | ||
|
|
||
| def _extract_finished_args(self, tool_calls: List[Dict[str, Any]]) -> Optional[str]: | ||
| """Extract final_answer from finished tool call if present.""" | ||
| try: | ||
| for tc in tool_calls: | ||
| f = tc.get("function") if isinstance(tc, dict) else None | ||
| if f and f.get("name") == "finished": | ||
| raw_args = f.get("arguments") | ||
| if isinstance(raw_args, str): | ||
| try: | ||
| args = json.loads(raw_args) | ||
| return args.get("final_answer") | ||
| except Exception: | ||
| return None | ||
| if isinstance(raw_args, dict): | ||
| return raw_args.get("final_answer") | ||
| return None | ||
| except Exception: | ||
| return None | ||
|
|
||
| async def run( | ||
| self, | ||
| *, | ||
| model: str, | ||
| messages: List[Dict[str, Any]], | ||
| context: AgentContext, | ||
| selected_tools: Optional[List[str]], | ||
| data_sources: Optional[List[str]], | ||
| max_steps: int, | ||
| temperature: float, | ||
| event_handler: AgentEventHandler, | ||
| ) -> AgentResult: | ||
| await event_handler(AgentEvent(type="agent_start", payload={"max_steps": max_steps, "strategy": "act"})) | ||
|
|
||
| steps = 0 | ||
| final_answer: Optional[str] = None | ||
|
|
||
| # Define the "finished" control tool | ||
| finished_tool_schema = { | ||
| "type": "function", | ||
| "function": { | ||
| "name": "finished", | ||
| "description": "Call this when you have completed the task and are ready to provide a final answer to the user.", | ||
| "parameters": { | ||
| "type": "object", | ||
| "properties": { | ||
| "final_answer": { | ||
| "type": "string", | ||
| "description": "The final response to provide to the user", | ||
| }, | ||
| }, | ||
| "required": ["final_answer"], | ||
| "additionalProperties": False, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| while steps < max_steps and final_answer is None: | ||
| steps += 1 | ||
| await event_handler(AgentEvent(type="agent_turn_start", payload={"step": steps})) | ||
|
|
||
| # Build tools schema: user tools + finished tool | ||
| tools_schema: List[Dict[str, Any]] = [finished_tool_schema] | ||
| if selected_tools and self.tool_manager: | ||
| user_tools = await error_utils.safe_get_tools_schema(self.tool_manager, selected_tools) | ||
| tools_schema.extend(user_tools) | ||
|
|
||
| # Call LLM with tools - using "required" to force tool calling during Act phase | ||
| # The LiteLLM caller has fallback logic to "auto" if "required" is not supported | ||
| if data_sources and context.user_email: | ||
| llm_response = await self.llm.call_with_rag_and_tools( | ||
| model, messages, data_sources, tools_schema, context.user_email, "required", temperature=temperature | ||
| ) | ||
| else: | ||
| llm_response = await self.llm.call_with_tools( | ||
| model, messages, tools_schema, "required", temperature=temperature | ||
| ) | ||
|
|
||
| # Process response | ||
| if llm_response.has_tool_calls(): | ||
| tool_calls = llm_response.tool_calls or [] | ||
|
|
||
| # Check if finished tool was called | ||
| final_answer = self._extract_finished_args(tool_calls) | ||
| if final_answer: | ||
| break | ||
|
|
||
| # Execute first non-finished tool call | ||
| first_call = None | ||
| for tc in tool_calls: | ||
| f = tc.get("function") if isinstance(tc, dict) else None | ||
| if f and f.get("name") != "finished": | ||
| first_call = tc | ||
| break | ||
|
|
||
| if first_call is None: | ||
| # Only finished tool or no valid tools | ||
| final_answer = llm_response.content or "Task completed." | ||
| break | ||
|
|
||
| # Execute the tool | ||
| messages.append({ | ||
| "role": "assistant", | ||
| "content": llm_response.content, | ||
| "tool_calls": [first_call], | ||
| }) | ||
|
|
||
| result = await tool_utils.execute_single_tool( | ||
| tool_call=first_call, | ||
| session_context={ | ||
| "session_id": context.session_id, | ||
| "user_email": context.user_email, | ||
| "files": context.files, | ||
| }, | ||
| tool_manager=self.tool_manager, | ||
| update_callback=(self.connection.send_json if self.connection else None), | ||
| ) | ||
|
|
||
| messages.append({ | ||
| "role": "tool", | ||
| "content": result.content, | ||
| "tool_call_id": result.tool_call_id, | ||
| }) | ||
|
|
||
| # Emit tool results for artifact ingestion | ||
| await event_handler(AgentEvent(type="agent_tool_results", payload={"results": [result]})) | ||
| else: | ||
| # No tool calls - treat content as final answer | ||
| final_answer = llm_response.content or "Task completed." | ||
| break | ||
|
|
||
| # Fallback if no final answer after max steps | ||
| if not final_answer: | ||
| final_answer = await self.llm.call_plain(model, messages, temperature=temperature) | ||
|
|
||
| await event_handler(AgentEvent(type="agent_completion", payload={"steps": steps})) | ||
| return AgentResult(final_answer=final_answer, steps=steps, metadata={"agent_mode": True, "strategy": "act"}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| """Factory for creating agent loop instances based on strategy.""" | ||
|
|
||
| import logging | ||
| from typing import Optional | ||
|
|
||
| from interfaces.llm import LLMProtocol | ||
| from interfaces.tools import ToolManagerProtocol | ||
| from interfaces.transport import ChatConnectionProtocol | ||
| from modules.prompts.prompt_provider import PromptProvider | ||
|
|
||
| from .protocols import AgentLoopProtocol | ||
| from .react_loop import ReActAgentLoop | ||
| from .think_act_loop import ThinkActAgentLoop | ||
| from .act_loop import ActAgentLoop | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class AgentLoopFactory: | ||
| """ | ||
| Factory for creating agent loop instances. | ||
|
|
||
| This factory pattern allows for easy addition of new agent loop strategies | ||
| without modifying existing code. Simply add a new strategy to the registry. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| llm: LLMProtocol, | ||
| tool_manager: Optional[ToolManagerProtocol] = None, | ||
| prompt_provider: Optional[PromptProvider] = None, | ||
| connection: Optional[ChatConnectionProtocol] = None, | ||
| ): | ||
| """ | ||
| Initialize factory with shared dependencies. | ||
|
|
||
| Args: | ||
| llm: LLM protocol implementation | ||
| tool_manager: Optional tool manager | ||
| prompt_provider: Optional prompt provider | ||
| connection: Optional connection for sending updates | ||
| """ | ||
| self.llm = llm | ||
| self.tool_manager = tool_manager | ||
| self.prompt_provider = prompt_provider | ||
| self.connection = connection | ||
|
|
||
| # Registry of available strategies | ||
| self._strategy_registry = { | ||
| "react": ReActAgentLoop, | ||
| "think-act": ThinkActAgentLoop, | ||
| "think_act": ThinkActAgentLoop, | ||
| "thinkact": ThinkActAgentLoop, | ||
| "act": ActAgentLoop, | ||
| } | ||
|
|
||
| # Cache of instantiated loops for performance | ||
| self._loop_cache: dict[str, AgentLoopProtocol] = {} | ||
|
|
||
| def create(self, strategy: str = "think-act") -> AgentLoopProtocol: | ||
| """ | ||
| Create an agent loop instance for the given strategy. | ||
|
|
||
| Args: | ||
| strategy: Strategy name (react, think-act, act, etc.) | ||
|
|
||
| Returns: | ||
| AgentLoopProtocol instance | ||
|
|
||
| Note: | ||
| If the strategy is not recognized, falls back to 'react' with a warning. | ||
| """ | ||
| strategy_normalized = strategy.lower().strip() | ||
|
|
||
| # Check cache first | ||
| if strategy_normalized in self._loop_cache: | ||
| logger.info(f"Using agent loop strategy: {strategy_normalized}") | ||
| return self._loop_cache[strategy_normalized] | ||
|
|
||
| # Look up strategy in registry | ||
| loop_class = self._strategy_registry.get(strategy_normalized) | ||
|
|
||
| if loop_class is None: | ||
| logger.warning( | ||
| f"Unknown agent loop strategy '{strategy}', falling back to 'react'" | ||
| ) | ||
| loop_class = self._strategy_registry["react"] | ||
| strategy_normalized = "react" | ||
|
|
||
| # Instantiate the loop | ||
| loop_instance = loop_class( | ||
| llm=self.llm, | ||
| tool_manager=self.tool_manager, | ||
| prompt_provider=self.prompt_provider, | ||
| connection=self.connection, | ||
| ) | ||
|
|
||
| # Cache for future use | ||
| self._loop_cache[strategy_normalized] = loop_instance | ||
|
|
||
| logger.info(f"Created and using agent loop strategy: {strategy_normalized}") | ||
| return loop_instance | ||
|
|
||
| def get_available_strategies(self) -> list[str]: | ||
| """ | ||
| Get list of available strategy names. | ||
|
|
||
| Returns: | ||
| List of strategy identifiers | ||
| """ | ||
| # Return unique strategy names (deduplicated) | ||
| unique_strategies = set() | ||
| for strategy in self._strategy_registry.keys(): | ||
| # Normalize to primary name | ||
| if strategy in ("react",): | ||
| unique_strategies.add("react") | ||
| elif strategy in ("think-act", "think_act", "thinkact"): | ||
| unique_strategies.add("think-act") | ||
| elif strategy in ("act",): | ||
| unique_strategies.add("act") | ||
| return sorted(unique_strategies) | ||
|
|
||
| def register_strategy(self, name: str, loop_class: type[AgentLoopProtocol]) -> None: | ||
| """ | ||
| Register a new agent loop strategy. | ||
|
|
||
| This allows for dynamic extension of available strategies. | ||
|
|
||
| Args: | ||
| name: Strategy identifier | ||
| loop_class: Agent loop class to instantiate | ||
| """ | ||
| name_normalized = name.lower().strip() | ||
| self._strategy_registry[name_normalized] = loop_class | ||
| logger.info(f"Registered new agent loop strategy: {name_normalized}") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as think_act_loop.py: changing tool_choice from 'auto' to 'required' could cause failures with LLM providers that don't support 'required'. This behavior change needs test coverage or should be reverted to maintain compatibility.