diff --git a/.github/README.md b/.github/README.md index 592d5f8..87eea63 100644 --- a/.github/README.md +++ b/.github/README.md @@ -19,14 +19,7 @@ A simple coding agent. ## How it Works **Source Agent** operates as a stateless entity, guided by clear directives and external context. Its behavior is primarily defined by **`AGENTS.md`**, which serves as the core system prompt. -![](docs/example.gif) - ---- - -## Prerequisites -- Python 3.10 or higher -- An API key from one of the supported AI providers (see [Supported Providers](#supported-providers)) -- Git (for .gitignore support) +![](docs/example4.gif) --- @@ -42,7 +35,7 @@ python -m venv .venv source .venv/bin/activate # On Windows: .venv\Scripts\activate # Install in development mode -pip install -e ".[developer]" +pip install --editable ".[developer]" # Verify the installation source-agent --help @@ -116,22 +109,3 @@ Source Agent provides these built-in tools for code analysis: - **msg_complete_tool** - REQUIRED tool to signal task completion and exit the agent loop These tools are automatically available to the AI agent during analysis. - ---- - -## Core Architecture -- **Entry Point**: `src/source_agent/entrypoint.py` - CLI interface with argument parsing -- **Agent Engine**: `src/source_agent/agents/code.py` - OpenAI-compatible client with tool integration -- **System Prompt**: `AGENTS.md` - Defines agent behavior, roles, and constraints - -### Project Structure - -``` -source-agent/ -├── src/source_agent/ -│ ├── entrypoint.py # CLI interface -│ ├── agents/ -│ │ └── code.py # Main agent logic -│ └── tools/ # File system tools -└── AGENTS.md # System prompt & behavior rules -``` diff --git a/.github/docs/example4.gif b/.github/docs/example4.gif new file mode 100644 index 0000000..f37f338 Binary files /dev/null and b/.github/docs/example4.gif differ diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e69de29..0000000 diff --git a/config.yaml b/config.yaml deleted file mode 100644 index 4077028..0000000 --- a/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -orchestrator: - parallel_agents: 2 - task_timeout: 300 - aggregation_strategy: consensus - question_generation_prompt: | - Generate {num_agents} JSON-array questions to explore: "{user_input}" - synthesis_prompt: | - You are to synthesize {num_responses} inputs: - {agent_responses} - Produce a final coherent summary. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 20c5e1d..c9e3acd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,10 @@ build-backend = "hatchling.build" [project.scripts] source-agent = "source_agent.entrypoint:main" -heavy-agent = "source_agent.heavy:main" [project] requires-python = ">=3.10" -version = "0.0.13" +version = "0.0.14" name = "source-agent" description = "Simple coding agent." readme = ".github/README.md" diff --git a/src/source_agent/agents/code.py b/src/source_agent/agents/code.py index 5740544..e164bd4 100644 --- a/src/source_agent/agents/code.py +++ b/src/source_agent/agents/code.py @@ -1,10 +1,38 @@ import re +import sys import json import time import openai import random import source_agent +from enum import Enum +from typing import Any, Dict, Iterator from pathlib import Path +from dataclasses import field, dataclass + + +class AgentEventType(Enum): + ITERATION_START = "iteration_start" + AGENT_MESSAGE = "agent_message" + TOOL_CALL = "tool_call" + TOOL_RESULT = "tool_result" + TASK_COMPLETE = "task_complete" + MAX_STEPS_REACHED = "max_steps_reached" + ERROR = "error" + + +@dataclass(frozen=True) +class AgentEvent: + """ + Represents an event occurring during the agent's operation. + + Attributes: + type: The type of event (e.g., iteration_start, agent_message). + data: A dictionary containing event-specific data. + """ + + type: AgentEventType + data: Dict[str, Any] = field(default_factory=dict) class CodeAgent: @@ -17,10 +45,10 @@ class CodeAgent: def __init__( self, - api_key=None, - base_url=None, - model=None, - temperature=0.3, + api_key: str = None, + base_url: str = None, + model: str = None, + temperature: float = 0.3, system_prompt: str = None, ): self.api_key = api_key @@ -46,13 +74,18 @@ def reset_conversation(self): """Clear conversation and initialize with system prompt.""" self.messages = [{"role": "system", "content": self.system_prompt}] - def run(self, user_prompt: str = None, max_steps: int = None): + def run( + self, user_prompt: str = None, max_steps: int = None + ) -> Iterator[AgentEvent]: """ - Run a full ReAct-style loop with tool usage. + Run a full ReAct-style loop with tool usage, yielding events at each step. Args: user_prompt: Optional user input to start the conversation. max_steps: Maximum steps before stopping. + + Yields: + AgentEvent: An event describing the current state or action of the agent. """ if user_prompt: self.messages.append({"role": "user", "content": user_prompt}) @@ -60,31 +93,69 @@ def run(self, user_prompt: str = None, max_steps: int = None): steps = max_steps or self.MAX_STEPS for step in range(1, steps + 1): - print(f"🔄 Iteration {step}/{steps}") - response = self.call_llm(self.messages) + yield AgentEvent( + type=AgentEventType.ITERATION_START, + data={"step": step, "max_steps": steps}, + ) + + try: + response = self.call_llm(self.messages) + except Exception as e: + yield AgentEvent( + type=AgentEventType.ERROR, + data={ + "message": f"LLM call failed: {str(e)}", + "exception_type": type(e).__name__, + }, + ) + return message = response.choices[0].message self.messages.append(message) parsed_content = self.parse_response_message(message.content) if parsed_content: - print("🤖 Agent:", parsed_content) + yield AgentEvent( + type=AgentEventType.AGENT_MESSAGE, data={"content": parsed_content} + ) if message.tool_calls: for tool_call in message.tool_calls: tool_name = tool_call.function.name - print(f"🔧 Calling: {tool_name}") + yield AgentEvent( + type=AgentEventType.TOOL_CALL, + data={ + "name": tool_name, + "arguments": tool_call.function.arguments, + }, + ) if tool_name == "msg_complete_tool": - print("💯 Task marked complete!\n") + yield AgentEvent( + type=AgentEventType.TASK_COMPLETE, + data={"message": "Task marked complete!"}, + ) return - result = self.handle_tool_call(tool_call) - self.messages.append(result) - - print("-" * 40 + "\n") - - return {"error": "Max steps reached without task completion."} + result_message = self.handle_tool_call(tool_call) + self.messages.append(result_message) + # Attempt to parse tool result content as JSON if it's a string, otherwise use as-is + tool_result_content = result_message["content"] + try: + parsed_tool_result = json.loads(tool_result_content) + except (json.JSONDecodeError, TypeError): + # Fallback to string representation for complex types + parsed_tool_result = tool_result_content + + yield AgentEvent( + type=AgentEventType.TOOL_RESULT, + data={"name": tool_name, "result": parsed_tool_result}, + ) + + yield AgentEvent( + type=AgentEventType.MAX_STEPS_REACHED, + data={"message": f"Max steps ({steps}) reached without task completion."}, + ) def parse_response_message(self, message: str) -> str: """ @@ -119,6 +190,11 @@ def handle_tool_call(self, tool_call): return self._tool_error(tool_call, f"Unknown tool: {tool_name}") result = func(**tool_args) + # Ensure result is always JSON serializable for the 'content' field of the tool message + # This is important for the LLM to process it correctly + if not isinstance(result, (str, dict, list, int, float, bool, type(None))): + result = str(result) + return { "role": "tool", "tool_call_id": tool_call.id, @@ -160,19 +236,22 @@ def call_llm( The response from the chat API. Raises: - openai.Timeout: If the API call times out. - openai.APIError: If the API call fails due to an API error. - openai.OpenAIError: If the API call fails after retries. - openai.APIStatusError: If the API call fails due to an API status error. - openai.RateLimitError: If the API call exceeds the rate limit. - openai.APITimeoutError: If the API call times out. - openai.APIConnectionError: If the API call fails due to a connection error. + openai.OpenAIError: If the API call fails after retries due to an OpenAI-specific error. + Exception: For any other unexpected errors. """ retries = max_retries or self.MAX_RETRIES base = backoff_base or self.BACKOFF_BASE factor = backoff_factor or self.BACKOFF_FACTOR cap = max_backoff or self.MAX_BACKOFF + # Define specific OpenAI errors that are generally retryable. + RETRYABLE_OPENAI_ERRORS = ( + openai.RateLimitError, # 429 status code + openai.APITimeoutError, # Timeout during the API call + openai.APIConnectionError, # Network connection issues + openai.APIStatusError, # Covers 5xx errors which are often retryable, and also other 4xx errors + ) + for attempt in range(1, retries + 1): try: return self.session.chat.completions.create( @@ -182,26 +261,34 @@ def call_llm( tool_choice="auto", temperature=self.temperature, ) - except ( - openai.Timeout, - openai.APIError, - openai.OpenAIError, - openai.APIStatusError, - openai.RateLimitError, - openai.APITimeoutError, - openai.APIConnectionError, - ) as e: + except RETRYABLE_OPENAI_ERRORS as e: + # This block handles known retryable OpenAI API errors. if attempt == retries: - print(f"❌ LLM call failed after {attempt} attempts: {e}") - raise + print( + f"❌ LLM call failed after {attempt} attempts: {e}", + file=sys.stderr, + ) + raise # Re-raise if all retries exhausted delay = min(base * (factor ** (attempt - 1)) + random.random(), cap) print( f"⚠️ Attempt {attempt} failed: {type(e).__name__}: {e}. " - f"Retrying in {delay:.1f}s..." + f"Retrying in {delay:.1f}s...", + file=sys.stderr, ) time.sleep(delay) + except openai.OpenAIError as e: + # This block handles non-retryable OpenAI API errors (e.g., AuthenticationError, + # PermissionDeniedError, InvalidRequestError, etc.). + # These typically indicate a problem that retrying won't solve. + print( + f"❌ Non-retryable OpenAI error during LLM call: {e}", + file=sys.stderr, + ) + raise # Re-raise immediately + except Exception as e: - print(f"❌ Unexpected error during LLM call: {e}") + # This block catches any other unexpected Python exceptions. + print(f"❌ Unexpected error during LLM call: {e}", file=sys.stderr) raise diff --git a/src/source_agent/entrypoint.py b/src/source_agent/entrypoint.py index 935c99f..d6982a9 100644 --- a/src/source_agent/entrypoint.py +++ b/src/source_agent/entrypoint.py @@ -1,20 +1,56 @@ import sys +import json import argparse - -# https://docs.python.org/3/library/readline.html import source_agent -def run_prompt_mode(agent, prompt) -> str: +def handle_agent_events( + agent_events: source_agent.agents.code.AgentEvent, verbose: bool +): + """ + Handles and prints events yielded by the agent. + + Args: + agent_events: An iterator yielding AgentEvent objects. + verbose: If True, prints more detailed information (e.g., tool arguments). + """ + for event in agent_events: + if event.type == source_agent.agents.code.AgentEventType.ITERATION_START: + print("\n" + "-" * 40 + "\n") + print(f"🔄 Iteration {event.data['step']}/{event.data['max_steps']}") + elif event.type == source_agent.agents.code.AgentEventType.AGENT_MESSAGE: + print(f"🤖 Agent: {event.data['content']}") + elif event.type == source_agent.agents.code.AgentEventType.TOOL_CALL: + tool_name = event.data["name"] + tool_args = event.data["arguments"] + print(f"🔧 Calling: {tool_name}") + if verbose: + try: + print(f" Args: {json.dumps(json.loads(tool_args), indent=2)}") + except json.JSONDecodeError: + print(f" Raw Args: {tool_args}") + elif event.type == source_agent.agents.code.AgentEventType.TOOL_RESULT: + tool_name = event.data["name"] + tool_result = event.data["result"] + print(f"✅ Tool Result ({tool_name}): {json.dumps(tool_result, indent=2)}") + elif event.type == source_agent.agents.code.AgentEventType.TASK_COMPLETE: + # Exit generator iteration as task is complete + print(f"💯 {event.data['message']}\n") + return + elif event.type == source_agent.agents.code.AgentEventType.MAX_STEPS_REACHED: + print(f"🛑 {event.data['message']}") + elif event.type == source_agent.agents.code.AgentEventType.ERROR: + print(f"❌ Error: {event.data['message']}", file=sys.stderr) + + +def run_prompt_mode(agent, prompt: str, verbose: bool): """ - Dispatch the agent with the given prompt. + Dispatch the agent with the given prompt in autonomous mode. Args: agent: The agent instance to run. prompt: The prompt to provide to the agent. - - Returns: - The response from the agent. + verbose: If True, enables verbose output for agent events. """ user_prompt = ( "You are a helpful code assistant. Think step-by-step and use tools when needed.\n" @@ -22,24 +58,32 @@ def run_prompt_mode(agent, prompt) -> str: f"The user's prompt is:\n\n{prompt}" ) - return agent.run(user_prompt=user_prompt) + print("🚀 Running in autonomous mode...") + agent_events_generator = agent.run(user_prompt=user_prompt) + handle_agent_events(agent_events_generator, verbose) + + +def run_interactive_mode(agent, verbose: bool): + """ + Runs the agent in interactive mode, allowing user input and displaying agent progress. + Args: + agent: The agent instance to run. + verbose: If True, enables verbose output for agent events. + """ + history = [] -def run_interactive_mode(agent): print( """ 🧠 Entering interactive mode. 💡 Type your prompt and press ↵. Type ':exit' to quit, - ':reset' to start fresh - ':help' for commands. + Type ':reset' to start fresh, + Type ':help' for commands. """ ) - system_prompt = agent.system_prompt - history = [] - while True: try: user_input = input("🗣️ You > ").strip() @@ -52,10 +96,10 @@ def run_interactive_mode(agent): """ 🔧 Available commands: :exit Quit the session - :history Show conversation history - :reset Clear conversation history + :history Show conversation history (local to CLI) + :reset Clear conversation history (agent's memory) :help Show this help message - """ + """ ) continue @@ -64,31 +108,49 @@ def run_interactive_mode(agent): break if user_input.lower() == ":history": - print("📜 Conversation History:") + print("📜 Conversation History (Local CLI Log):") + if not history: + print(" (No history yet)") for i, msg in enumerate(history, 1): print(f"{i}. {msg}") continue if user_input.lower() == ":reset": - print("🔄 Conversation history reset.") - agent.messages = [{"role": "system", "content": system_prompt}] + print("🔄 Conversation history reset (for agent and CLI log).") + agent.reset_conversation() history.clear() continue - # Update message history - agent.messages.append({"role": "user", "content": user_input}) - + # Add user input to CLI history history.append(f"User: {user_input}") print("🤖 Thinking...\n") - response = agent.run() - if response: - print(f"\n🤖 Agent > {response.strip()}\n") + # Run the agent and iterate over its events + agent_events_generator = agent.run(user_prompt=user_input) + for event in agent_events_generator: + # Print the event using the handler + handle_agent_events([event], verbose) + + # In interactive mode, we might want to allow deeper interaction. + # For now, just continue processing. + # If a "task_complete" event is received, break the loop + if ( + event.type == source_agent.agents.code.AgentEventType.TASK_COMPLETE + or event.type + == source_agent.agents.code.AgentEventType.MAX_STEPS_REACHED + or event.type == source_agent.agents.code.AgentEventType.ERROR + ): + break except (KeyboardInterrupt, EOFError): print("\n👋 Session interrupted. Exiting.") break + except Exception as e: + print( + f"An unexpected error occurred in interactive mode: {e}", + file=sys.stderr, + ) def main() -> int: @@ -136,13 +198,6 @@ def main() -> int: default=0.3, help="Temperature for the model (default: 0.3)", ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - default=False, - help="Enable verbose logging", - ) parser.add_argument( "-i", "--interactive", @@ -153,10 +208,6 @@ def main() -> int: args = parser.parse_args() - # if args.verbose: - # # Logging setup? - # pass - api_key, base_url = source_agent.providers.get(args.provider) agent = source_agent.agents.code.CodeAgent( api_key=api_key, @@ -165,13 +216,14 @@ def main() -> int: temperature=args.temperature, ) - if args.interactive: - # Run in interactive mode - run_interactive_mode(agent) - - else: - # Let the agent run autonomously - run_prompt_mode(agent=agent, prompt=args.prompt) + try: + if args.interactive: + run_interactive_mode(agent, args.verbose) + else: + run_prompt_mode(agent=agent, prompt=args.prompt, verbose=args.verbose) + except Exception as e: + print(f"An unhandled error occurred: {e}", file=sys.stderr) + return 1 return 0 diff --git a/src/source_agent/heavy.py b/src/source_agent/heavy.py deleted file mode 100644 index 92fd75c..0000000 --- a/src/source_agent/heavy.py +++ /dev/null @@ -1,11 +0,0 @@ -import sys - - -def main(): - print("Heavy agent running...") - # Add heavy agent logic here - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/source_agent/orchestrator.py b/src/source_agent/orchestrator.py deleted file mode 100644 index e69de29..0000000