Files
SneakyCode/app/main.py
Phillip Tarrant 91187a0728 Add Phase 5: ReAct-style agent loop with tool execution
Implement the core autonomy layer — AgentLoop streams LLM responses,
parses tool calls, executes them with permission checks, feeds results
back, and repeats until the task completes or finish is called.

- Add FinishTool for explicit loop termination
- Add tools parameter to LLMClient.stream_chat() for function calling
- Add compact tool result display (status line, not full output)
- Refactor REPL to delegate to AgentLoop.run_turn()
- Fix Ollama null content rejection (always send content as string)
- Add finish to auto_approve permissions
- 9 unit tests for agent loop (34 total, zero regressions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:37:22 -05:00

167 lines
4.8 KiB
Python

"""SneakyCode entrypoint — argument parsing, config loading, and interactive REPL."""
import argparse
import asyncio
import sys
from pathlib import Path
import structlog
from app.agent.context import SessionContext
from app.agent.loop import AgentLoop
from app.models.config import AppConfig, load_config
from app.services.llm import LLMClient, LLMConnectionError, LLMError
from app.services.permissions import PermissionsService
from app.services.streaming import StreamHandler
from app.tools.registry import create_default_registry
from app.utils.display import (
print_banner,
print_error,
print_history,
print_info,
print_success,
print_user_message,
print_warning,
)
from app.utils.logging import console, get_logger, setup_logging
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments.
Returns:
Parsed arguments namespace.
"""
parser = argparse.ArgumentParser(
prog="sneakycode",
description="SneakyCode — A privacy-first local AI coding agent",
)
parser.add_argument(
"--config",
type=Path,
default=None,
help="Path to config YAML file (default: config/config.yaml)",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
default=False,
help="Enable verbose (DEBUG) logging",
)
parser.add_argument(
"--log-file",
type=Path,
default=None,
help="Path to log file for persistent logging",
)
return parser.parse_args()
async def _preflight(config: AppConfig) -> None:
"""Check that Ollama is reachable and the configured model is available."""
async with LLMClient(config.llm) as client:
await client.preflight_check()
async def _run_repl(
ctx: SessionContext,
config: AppConfig,
logger: structlog.stdlib.BoundLogger,
) -> None:
"""Run the interactive REPL loop with streaming LLM responses.
Args:
ctx: Session context for conversation state.
config: Application configuration.
logger: Structured logger instance.
"""
registry = create_default_registry(config.agent.workspace_root, config)
permissions = PermissionsService(config.permissions)
async with LLMClient(config.llm) as client:
handler = StreamHandler(config.display)
agent = AgentLoop(config, ctx, client, handler, registry, permissions)
while True:
try:
user_input = console.input("[bold cyan]> [/bold cyan]")
except (KeyboardInterrupt, EOFError):
console.print("\n[dim]Goodbye![/dim]")
break
user_input = user_input.strip()
if not user_input:
continue
# Handle slash commands
if user_input.startswith("/"):
command = user_input.lower()
if command == "/quit":
console.print("[dim]Goodbye![/dim]")
break
elif command == "/history":
print_history(ctx.get_history())
elif command == "/clear":
ctx.clear_history()
print_success("Conversation history cleared.")
else:
print_warning(f"Unknown command: {user_input}")
continue
print_user_message(user_input)
await agent.run_turn(user_input)
logger.debug("turn_complete", message_count=ctx.message_count)
def main() -> None:
"""Main entrypoint: load config, setup logging, launch interactive REPL."""
args = parse_args()
# Setup logging first
setup_logging(
log_file=args.log_file,
verbose=args.verbose,
)
logger = get_logger(__name__)
# Load configuration
try:
config = load_config(config_path=args.config)
except (FileNotFoundError, ValueError) as e:
print_error(f"Configuration error: {e}")
sys.exit(1)
logger.info("config_loaded", model=config.llm.model, endpoint=config.llm.endpoint)
# Print startup info
print_banner()
print_info(f"Model: {config.llm.model}")
print_info(f"Endpoint: {config.llm.endpoint}")
print_info(f"Workspace: {config.agent.workspace_root}")
if args.verbose:
print_info("Verbose mode enabled")
# Preflight: check Ollama is reachable and model exists
try:
asyncio.run(_preflight(config))
except LLMConnectionError as e:
print_error(str(e))
sys.exit(1)
except LLMError as e:
print_error(str(e))
sys.exit(1)
print_success("Ollama connected, model ready.")
# Create session and start REPL
ctx = SessionContext(config)
logger.info("startup_complete")
print_info("Commands: /quit, /history, /clear")
asyncio.run(_run_repl(ctx, config, logger))
if __name__ == "__main__":
main()