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>
167 lines
4.8 KiB
Python
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()
|