init commit
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/agent/__init__.py
Normal file
0
app/agent/__init__.py
Normal file
72
app/agent/context.py
Normal file
72
app/agent/context.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Session state and conversation history manager."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.models.config import AppConfig
|
||||
from app.models.message import Message
|
||||
from app.utils.token_counter import TokenCounter
|
||||
|
||||
|
||||
class SessionContext:
|
||||
"""In-memory conversation state manager.
|
||||
|
||||
Tracks conversation history, token usage estimates, and session metadata.
|
||||
"""
|
||||
|
||||
def __init__(self, config: AppConfig) -> None:
|
||||
"""Initialize session context.
|
||||
|
||||
Args:
|
||||
config: Application configuration.
|
||||
"""
|
||||
self._config = config
|
||||
self._history: list[Message] = []
|
||||
self._token_counter = TokenCounter(config.agent.max_conversation_tokens)
|
||||
self._start_time = datetime.now(UTC)
|
||||
self._message_count: int = 0
|
||||
|
||||
def add_message(self, role: str, content: str | None = None, **kwargs: object) -> Message:
|
||||
"""Create and append a message to conversation history.
|
||||
|
||||
Args:
|
||||
role: Message role (system, user, assistant, tool).
|
||||
content: Text content of the message.
|
||||
**kwargs: Additional Message fields (tool_calls, tool_call_id, name).
|
||||
|
||||
Returns:
|
||||
The created Message instance.
|
||||
"""
|
||||
message = Message(role=role, content=content, **kwargs) # type: ignore[arg-type]
|
||||
self._history.append(message)
|
||||
self._message_count += 1
|
||||
return message
|
||||
|
||||
def get_history(self) -> list[Message]:
|
||||
"""Return a shallow copy of the conversation history."""
|
||||
return list(self._history)
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear conversation history and reset counters."""
|
||||
self._history.clear()
|
||||
self._message_count = 0
|
||||
self._token_counter = TokenCounter(self._config.agent.max_conversation_tokens)
|
||||
|
||||
@property
|
||||
def estimated_tokens(self) -> int:
|
||||
"""Estimated token count for the current conversation history."""
|
||||
return self._token_counter.estimate_messages_tokens(self._history)
|
||||
|
||||
@property
|
||||
def token_counter(self) -> TokenCounter:
|
||||
"""The token counter instance."""
|
||||
return self._token_counter
|
||||
|
||||
@property
|
||||
def message_count(self) -> int:
|
||||
"""Number of messages added to this session."""
|
||||
return self._message_count
|
||||
|
||||
@property
|
||||
def start_time(self) -> datetime:
|
||||
"""Session start timestamp (UTC)."""
|
||||
return self._start_time
|
||||
183
app/main.py
Normal file
183
app/main.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""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.models.config import AppConfig, load_config
|
||||
from app.services.llm import LLMClient, LLMConnectionError, LLMError
|
||||
from app.services.streaming import StreamHandler
|
||||
from app.utils.display import (
|
||||
print_banner,
|
||||
print_error,
|
||||
print_history,
|
||||
print_info,
|
||||
print_success,
|
||||
print_token_usage,
|
||||
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 _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.
|
||||
"""
|
||||
async with LLMClient(config.llm) as client:
|
||||
handler = StreamHandler(config.display)
|
||||
|
||||
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
|
||||
|
||||
# Add user message and display it
|
||||
ctx.add_message("user", user_input)
|
||||
print_user_message(user_input)
|
||||
|
||||
# Stream LLM response
|
||||
try:
|
||||
chunk_iter = client.stream_chat(ctx.get_history())
|
||||
assistant_msg = await handler.process_stream(chunk_iter)
|
||||
except KeyboardInterrupt:
|
||||
print_warning("Response interrupted.")
|
||||
handler.reset()
|
||||
continue
|
||||
except LLMConnectionError as e:
|
||||
print_error(f"Connection error: {e}")
|
||||
continue
|
||||
except LLMError as e:
|
||||
print_error(f"LLM error: {e}")
|
||||
continue
|
||||
|
||||
# Handle empty response
|
||||
if assistant_msg.content is None and assistant_msg.tool_calls is None:
|
||||
print_warning("Received empty response from model.")
|
||||
|
||||
# Record assistant message in history
|
||||
ctx.add_message(
|
||||
"assistant",
|
||||
assistant_msg.content,
|
||||
tool_calls=assistant_msg.tool_calls,
|
||||
)
|
||||
|
||||
# Record API token usage if available, fall back to heuristic
|
||||
if handler.usage:
|
||||
ctx.token_counter.count_usage(handler.usage)
|
||||
|
||||
# Show token usage if configured
|
||||
if config.display.show_token_usage:
|
||||
print_token_usage(
|
||||
ctx.token_counter.cumulative_usage.total_tokens
|
||||
or ctx.estimated_tokens,
|
||||
ctx.token_counter.budget,
|
||||
)
|
||||
|
||||
handler.reset()
|
||||
logger.debug("message_exchanged", 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")
|
||||
|
||||
# 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()
|
||||
15
app/models/__init__.py
Normal file
15
app/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""SneakyCode data models."""
|
||||
|
||||
from app.models.config import AppConfig, load_config
|
||||
from app.models.message import Message
|
||||
from app.models.tool_call import ToolCall, ToolCallFunction, ToolResult, ToolResultStatus
|
||||
|
||||
__all__ = [
|
||||
"AppConfig",
|
||||
"load_config",
|
||||
"Message",
|
||||
"ToolCall",
|
||||
"ToolCallFunction",
|
||||
"ToolResult",
|
||||
"ToolResultStatus",
|
||||
]
|
||||
127
app/models/config.py
Normal file
127
app/models/config.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Pydantic configuration models mapping to config/config.yaml."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
"""LLM backend configuration."""
|
||||
|
||||
model: str = Field(description="Model name to use")
|
||||
endpoint: str = Field(description="Base URL of the LLM API")
|
||||
api_path: str = Field(default="/v1/chat/completions", description="API endpoint path")
|
||||
temperature: float = Field(default=0.1, description="Sampling temperature")
|
||||
max_tokens: int = Field(default=4096, description="Maximum tokens in LLM response")
|
||||
timeout: int = Field(default=120, description="Request timeout in seconds")
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
"""Agent loop configuration."""
|
||||
|
||||
max_iterations: int = Field(default=25, description="Maximum tool-call loop iterations")
|
||||
max_conversation_tokens: int = Field(
|
||||
default=32000, description="Token budget for conversation history"
|
||||
)
|
||||
workspace_root: Path = Field(
|
||||
default=Path("."), description="Root directory for file operations"
|
||||
)
|
||||
|
||||
|
||||
class PermissionsConfig(BaseModel):
|
||||
"""Tool permission tiers."""
|
||||
|
||||
auto_approve: list[str] = Field(default_factory=list, description="Auto-approved tools")
|
||||
prompt_user: list[str] = Field(default_factory=list, description="Tools requiring confirmation")
|
||||
deny: list[str] = Field(default_factory=list, description="Tools that are blocked entirely")
|
||||
|
||||
|
||||
class ShellToolConfig(BaseModel):
|
||||
"""Shell tool restrictions."""
|
||||
|
||||
allowed_commands: list[str] = Field(default_factory=list, description="Allowed shell commands")
|
||||
denied_commands: list[str] = Field(default_factory=list, description="Blocked shell commands")
|
||||
max_output_bytes: int = Field(default=65536, description="Max output capture size in bytes")
|
||||
|
||||
|
||||
class FilesystemToolConfig(BaseModel):
|
||||
"""Filesystem tool limits."""
|
||||
|
||||
max_file_size_bytes: int = Field(default=1_048_576, description="Max file size for read/write")
|
||||
binary_detection: bool = Field(default=True, description="Detect and reject binary files")
|
||||
|
||||
|
||||
class ToolsConfig(BaseModel):
|
||||
"""Aggregate tool configuration."""
|
||||
|
||||
shell: ShellToolConfig = Field(default_factory=ShellToolConfig)
|
||||
filesystem: FilesystemToolConfig = Field(default_factory=FilesystemToolConfig)
|
||||
|
||||
|
||||
class DisplayConfig(BaseModel):
|
||||
"""Terminal display preferences."""
|
||||
|
||||
show_tool_calls: bool = Field(default=True, description="Show tool call details in output")
|
||||
show_token_usage: bool = Field(default=True, description="Show token usage stats")
|
||||
stream_output: bool = Field(default=True, description="Stream LLM output to terminal")
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
"""Top-level application configuration composing all sub-configs."""
|
||||
|
||||
llm: LLMConfig
|
||||
agent: AgentConfig = Field(default_factory=AgentConfig)
|
||||
permissions: PermissionsConfig = Field(default_factory=PermissionsConfig)
|
||||
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||
display: DisplayConfig = Field(default_factory=DisplayConfig)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def resolve_workspace_root(self) -> "AppConfig":
|
||||
"""Resolve workspace_root to an absolute path."""
|
||||
self.agent.workspace_root = self.agent.workspace_root.resolve()
|
||||
return self
|
||||
|
||||
|
||||
# Default config file location relative to project root
|
||||
_DEFAULT_CONFIG_PATH = Path("config/config.yaml")
|
||||
|
||||
|
||||
def load_config(config_path: Path | None = None) -> AppConfig:
|
||||
"""Load and validate application config from YAML.
|
||||
|
||||
Resolution order:
|
||||
1. Explicit config_path argument
|
||||
2. SNEAKYCODE_CONFIG environment variable
|
||||
3. config/config.yaml (default)
|
||||
|
||||
Args:
|
||||
config_path: Optional explicit path to config file.
|
||||
|
||||
Returns:
|
||||
Validated AppConfig instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the resolved config file does not exist.
|
||||
ValueError: If the config file is invalid YAML or fails validation.
|
||||
"""
|
||||
if config_path is None:
|
||||
env_path = os.environ.get("SNEAKYCODE_CONFIG")
|
||||
if env_path:
|
||||
config_path = Path(env_path)
|
||||
else:
|
||||
config_path = _DEFAULT_CONFIG_PATH
|
||||
|
||||
config_path = config_path.resolve()
|
||||
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||
|
||||
with open(config_path) as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Config file must contain a YAML mapping, got {type(raw).__name__}")
|
||||
|
||||
return AppConfig(**raw)
|
||||
50
app/models/message.py
Normal file
50
app/models/message.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Message schema for LLM conversation history."""
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.tool_call import ToolCall
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""A single message in the conversation history.
|
||||
|
||||
Follows the OpenAI chat completions message format with support for
|
||||
system, user, assistant, and tool roles.
|
||||
"""
|
||||
|
||||
role: Literal["system", "user", "assistant", "tool"] = Field(
|
||||
description="Role of the message sender"
|
||||
)
|
||||
content: str | None = Field(default=None, description="Text content of the message")
|
||||
tool_calls: list[ToolCall] | None = Field(
|
||||
default=None, description="Tool calls made by the assistant"
|
||||
)
|
||||
tool_call_id: str | None = Field(
|
||||
default=None, description="ID of the tool call this message responds to (role=tool)"
|
||||
)
|
||||
name: str | None = Field(
|
||||
default=None, description="Name of the tool that produced this message (role=tool)"
|
||||
)
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to a dict suitable for the OpenAI-compatible API.
|
||||
|
||||
Strips None-valued fields to keep the payload clean.
|
||||
"""
|
||||
data: dict[str, Any] = {"role": self.role}
|
||||
|
||||
if self.content is not None:
|
||||
data["content"] = self.content
|
||||
|
||||
if self.tool_calls is not None:
|
||||
data["tool_calls"] = [tc.model_dump() for tc in self.tool_calls]
|
||||
|
||||
if self.tool_call_id is not None:
|
||||
data["tool_call_id"] = self.tool_call_id
|
||||
|
||||
if self.name is not None:
|
||||
data["name"] = self.name
|
||||
|
||||
return data
|
||||
37
app/models/tool_call.py
Normal file
37
app/models/tool_call.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Tool call and result models following OpenAI function-calling spec."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ToolCallFunction(BaseModel):
|
||||
"""Function details within a tool call."""
|
||||
|
||||
name: str = Field(description="Name of the tool function to call")
|
||||
arguments: str = Field(description="JSON-encoded string of function arguments")
|
||||
|
||||
|
||||
class ToolCall(BaseModel):
|
||||
"""A single tool call from the LLM, per OpenAI spec."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this tool call")
|
||||
type: str = Field(default="function", description="Type of tool call")
|
||||
function: ToolCallFunction = Field(description="Function to invoke")
|
||||
|
||||
|
||||
class ToolResultStatus(StrEnum):
|
||||
"""Status of a tool execution result."""
|
||||
|
||||
SUCCESS = "success"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
"""Result of executing a tool call."""
|
||||
|
||||
tool_call_id: str = Field(description="ID of the tool call this result corresponds to")
|
||||
tool_name: str = Field(description="Name of the tool that was executed")
|
||||
status: ToolResultStatus = Field(description="Whether the tool execution succeeded or failed")
|
||||
output: str = Field(default="", description="Tool output on success")
|
||||
error: str | None = Field(default=None, description="Error message on failure")
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
124
app/services/llm.py
Normal file
124
app/services/llm.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""LLM client wrapper for Ollama / OpenAI-compatible endpoints."""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Self
|
||||
|
||||
import httpx
|
||||
|
||||
from app.models.config import LLMConfig
|
||||
from app.models.message import Message
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# --- Exception hierarchy ---
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""Base exception for LLM client errors."""
|
||||
|
||||
|
||||
class LLMConnectionError(LLMError):
|
||||
"""Connection or timeout failure when reaching the LLM endpoint."""
|
||||
|
||||
|
||||
class LLMResponseError(LLMError):
|
||||
"""Non-2xx HTTP response from the LLM endpoint."""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class LLMStreamError(LLMError):
|
||||
"""Malformed SSE data during streaming."""
|
||||
|
||||
|
||||
# --- Client ---
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Async streaming client for OpenAI-compatible chat completions.
|
||||
|
||||
Designed for Ollama but works with any endpoint implementing the
|
||||
OpenAI /v1/chat/completions SSE streaming protocol.
|
||||
"""
|
||||
|
||||
def __init__(self, config: LLMConfig) -> None:
|
||||
"""Initialize the LLM client.
|
||||
|
||||
Args:
|
||||
config: LLM configuration (model, endpoint, timeout, etc.).
|
||||
"""
|
||||
self._config = config
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=config.endpoint,
|
||||
timeout=httpx.Timeout(config.timeout, connect=10.0),
|
||||
)
|
||||
|
||||
async def stream_chat(self, messages: list[Message]) -> AsyncIterator[dict]:
|
||||
"""Stream a chat completion request, yielding parsed SSE chunks.
|
||||
|
||||
Args:
|
||||
messages: Conversation history to send to the model.
|
||||
|
||||
Yields:
|
||||
Parsed JSON dicts from each SSE data line.
|
||||
|
||||
Raises:
|
||||
LLMConnectionError: On connection or timeout failures.
|
||||
LLMResponseError: On non-2xx HTTP status.
|
||||
LLMStreamError: On malformed SSE data (only if every line fails).
|
||||
"""
|
||||
payload = {
|
||||
"model": self._config.model,
|
||||
"messages": [m.to_api_dict() for m in messages],
|
||||
"stream": True,
|
||||
"temperature": self._config.temperature,
|
||||
"max_tokens": self._config.max_tokens,
|
||||
}
|
||||
|
||||
try:
|
||||
async with self._client.stream(
|
||||
"POST", self._config.api_path, json=payload
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
body = await response.aread()
|
||||
raise LLMResponseError(
|
||||
f"LLM returned {response.status_code}: {body.decode(errors='replace')}",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
|
||||
data = line[6:] # strip "data: " prefix
|
||||
|
||||
if data.strip() == "[DONE]":
|
||||
return
|
||||
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("malformed_sse_chunk", data=data[:200])
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
raise LLMConnectionError(f"Cannot connect to LLM endpoint: {e}") from e
|
||||
except httpx.TimeoutException as e:
|
||||
raise LLMConnectionError(f"LLM request timed out: {e}") from e
|
||||
except httpx.HTTPError as e:
|
||||
raise LLMError(f"HTTP error communicating with LLM: {e}") from e
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP client."""
|
||||
await self._client.aclose()
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
await self.close()
|
||||
150
app/services/streaming.py
Normal file
150
app/services/streaming.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Streaming response handler — accumulates SSE chunks into a complete Message."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from rich.live import Live
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from app.models.config import DisplayConfig
|
||||
from app.models.message import Message
|
||||
from app.models.tool_call import ToolCall, ToolCallFunction
|
||||
from app.utils.display import print_assistant_message
|
||||
from app.utils.logging import console, get_logger
|
||||
from app.utils.token_counter import TokenUsage
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class StreamHandler:
|
||||
"""Processes an SSE chunk stream into a Rich live display and final Message.
|
||||
|
||||
Accumulates content deltas and tool call fragments, renders a live Markdown
|
||||
panel during streaming, and produces a complete assistant Message on finish.
|
||||
"""
|
||||
|
||||
def __init__(self, display_config: DisplayConfig) -> None:
|
||||
"""Initialize the stream handler.
|
||||
|
||||
Args:
|
||||
display_config: Display preferences (streaming toggle, etc.).
|
||||
"""
|
||||
self._display_config = display_config
|
||||
self._accumulated_content: str = ""
|
||||
self._accumulated_reasoning: str = ""
|
||||
self._tool_calls: dict[int, dict[str, str]] = {}
|
||||
self._usage: TokenUsage | None = None
|
||||
|
||||
async def process_stream(self, chunk_iter: AsyncIterator[dict]) -> Message:
|
||||
"""Consume a chunk iterator, rendering live output and returning the final Message.
|
||||
|
||||
Args:
|
||||
chunk_iter: Async iterator of parsed SSE chunk dicts.
|
||||
|
||||
Returns:
|
||||
Complete assistant Message with accumulated content and tool calls.
|
||||
"""
|
||||
with Live(console=console, refresh_per_second=8) as live:
|
||||
async for chunk in chunk_iter:
|
||||
self._process_chunk(chunk)
|
||||
|
||||
# Show reasoning while waiting for content
|
||||
display_text = self._accumulated_content
|
||||
if not display_text and self._accumulated_reasoning:
|
||||
display_text = f"*thinking...*"
|
||||
|
||||
if display_text and self._display_config.stream_output:
|
||||
live.update(Markdown(display_text))
|
||||
|
||||
# Final static render
|
||||
if self._accumulated_content:
|
||||
print_assistant_message(self._accumulated_content)
|
||||
|
||||
tool_calls = self._build_tool_calls() or None
|
||||
return Message(
|
||||
role="assistant",
|
||||
content=self._accumulated_content or None,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
|
||||
def _process_chunk(self, chunk: dict) -> None:
|
||||
"""Extract content, tool calls, and usage from a single SSE chunk.
|
||||
|
||||
Args:
|
||||
chunk: Parsed JSON dict from one SSE data line.
|
||||
"""
|
||||
# Content delta
|
||||
choices = chunk.get("choices", [])
|
||||
if choices:
|
||||
delta = choices[0].get("delta", {})
|
||||
|
||||
content_piece = delta.get("content")
|
||||
if content_piece:
|
||||
self._accumulated_content += content_piece
|
||||
|
||||
# Reasoning tokens (e.g. qwen3.5 thinking mode)
|
||||
reasoning_piece = delta.get("reasoning")
|
||||
if reasoning_piece:
|
||||
self._accumulated_reasoning += reasoning_piece
|
||||
|
||||
# Tool call deltas (accumulated by index)
|
||||
for tc_delta in delta.get("tool_calls", []):
|
||||
idx = tc_delta.get("index", 0)
|
||||
if idx not in self._tool_calls:
|
||||
self._tool_calls[idx] = {
|
||||
"id": tc_delta.get("id", ""),
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
}
|
||||
entry = self._tool_calls[idx]
|
||||
if tc_delta.get("id"):
|
||||
entry["id"] = tc_delta["id"]
|
||||
func = tc_delta.get("function", {})
|
||||
if func.get("name"):
|
||||
entry["name"] += func["name"]
|
||||
if func.get("arguments"):
|
||||
entry["arguments"] += func["arguments"]
|
||||
|
||||
# Token usage (typically in the final chunk)
|
||||
usage_data = chunk.get("usage")
|
||||
if usage_data:
|
||||
self._usage = TokenUsage(
|
||||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||
total_tokens=usage_data.get("total_tokens", 0),
|
||||
)
|
||||
|
||||
def _build_tool_calls(self) -> list[ToolCall]:
|
||||
"""Convert accumulated tool call fragments into sorted ToolCall list.
|
||||
|
||||
Returns:
|
||||
List of ToolCall objects sorted by stream index.
|
||||
"""
|
||||
if not self._tool_calls:
|
||||
return []
|
||||
|
||||
result: list[ToolCall] = []
|
||||
for idx in sorted(self._tool_calls):
|
||||
entry = self._tool_calls[idx]
|
||||
result.append(
|
||||
ToolCall(
|
||||
id=entry["id"],
|
||||
type="function",
|
||||
function=ToolCallFunction(
|
||||
name=entry["name"],
|
||||
arguments=entry["arguments"],
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@property
|
||||
def usage(self) -> TokenUsage | None:
|
||||
"""Token usage reported by the API, if available."""
|
||||
return self._usage
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all accumulators for the next turn."""
|
||||
self._accumulated_content = ""
|
||||
self._accumulated_reasoning = ""
|
||||
self._tool_calls.clear()
|
||||
self._usage = None
|
||||
0
app/tools/__init__.py
Normal file
0
app/tools/__init__.py
Normal file
30
app/utils/__init__.py
Normal file
30
app/utils/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""SneakyCode shared utilities."""
|
||||
|
||||
from app.utils.file_helpers import (
|
||||
BinaryFileError,
|
||||
FileSizeError,
|
||||
PathSecurityError,
|
||||
check_file_size,
|
||||
is_binary_file,
|
||||
resolve_safe_path,
|
||||
safe_read_file,
|
||||
safe_write_file,
|
||||
)
|
||||
from app.utils.logging import console, get_logger, setup_logging
|
||||
from app.utils.token_counter import TokenCounter, TokenUsage
|
||||
|
||||
__all__ = [
|
||||
"BinaryFileError",
|
||||
"FileSizeError",
|
||||
"PathSecurityError",
|
||||
"TokenCounter",
|
||||
"TokenUsage",
|
||||
"check_file_size",
|
||||
"console",
|
||||
"get_logger",
|
||||
"is_binary_file",
|
||||
"resolve_safe_path",
|
||||
"safe_read_file",
|
||||
"safe_write_file",
|
||||
"setup_logging",
|
||||
]
|
||||
103
app/utils/display.py
Normal file
103
app/utils/display.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Rich terminal display helpers for SneakyCode."""
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.theme import Theme
|
||||
|
||||
from app.models.message import Message
|
||||
from app.utils.logging import console
|
||||
|
||||
# Custom theme for consistent styling across the application
|
||||
SNEAKYCODE_THEME = Theme(
|
||||
{
|
||||
"info": "cyan",
|
||||
"warning": "yellow",
|
||||
"error": "bold red",
|
||||
"success": "bold green",
|
||||
"tool": "magenta",
|
||||
"dim": "dim white",
|
||||
}
|
||||
)
|
||||
|
||||
# Apply the theme to the shared console
|
||||
console.push_theme(SNEAKYCODE_THEME)
|
||||
|
||||
|
||||
def print_banner() -> None:
|
||||
"""Print the SneakyCode startup banner."""
|
||||
console.print(
|
||||
"\n[bold cyan] SneakyCode[/bold cyan] [dim]— Local AI Coding Agent[/dim]\n",
|
||||
)
|
||||
|
||||
|
||||
def print_info(message: str) -> None:
|
||||
"""Print an informational message."""
|
||||
console.print(f"[info]{message}[/info]")
|
||||
|
||||
|
||||
def print_warning(message: str) -> None:
|
||||
"""Print a warning message."""
|
||||
console.print(f"[warning]⚠ {message}[/warning]")
|
||||
|
||||
|
||||
def print_error(message: str) -> None:
|
||||
"""Print an error message."""
|
||||
console.print(f"[error]✗ {message}[/error]")
|
||||
|
||||
|
||||
def print_success(message: str) -> None:
|
||||
"""Print a success message."""
|
||||
console.print(f"[success]✓ {message}[/success]")
|
||||
|
||||
|
||||
def print_user_message(content: str) -> None:
|
||||
"""Print a user message in a styled panel."""
|
||||
console.print(Panel(content, title="You", border_style="cyan", expand=False))
|
||||
|
||||
|
||||
def print_assistant_message(content: str) -> None:
|
||||
"""Print an assistant message in a styled panel."""
|
||||
console.print(Panel(content, title="Assistant", border_style="green", expand=False))
|
||||
|
||||
|
||||
def print_tool_call(name: str, args: str) -> None:
|
||||
"""Print a tool call summary (stub for Phase 4)."""
|
||||
console.print(f"[tool]Tool: {name}[/tool] [dim]{args}[/dim]")
|
||||
|
||||
|
||||
def print_token_usage(usage_tokens: int, budget: int) -> None:
|
||||
"""Print current token usage against budget."""
|
||||
console.print(f"[dim]Tokens: ~{usage_tokens:,} / {budget:,}[/dim]")
|
||||
|
||||
|
||||
def print_history(messages: list[Message]) -> None:
|
||||
"""Print conversation history as a Rich table.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages to display.
|
||||
"""
|
||||
if not messages:
|
||||
console.print("[dim]No messages in history.[/dim]")
|
||||
return
|
||||
|
||||
table = Table(title="Conversation History")
|
||||
table.add_column("#", style="dim", width=4)
|
||||
table.add_column("Role", width=10)
|
||||
table.add_column("Content")
|
||||
|
||||
role_styles = {
|
||||
"user": "cyan",
|
||||
"assistant": "green",
|
||||
"system": "yellow",
|
||||
"tool": "magenta",
|
||||
}
|
||||
|
||||
for i, msg in enumerate(messages, 1):
|
||||
style = role_styles.get(msg.role, "white")
|
||||
content = msg.content or "[dim](no content)[/dim]"
|
||||
# Truncate long content for display
|
||||
if len(content) > 120:
|
||||
content = content[:117] + "..."
|
||||
table.add_row(str(i), f"[{style}]{msg.role}[/{style}]", content)
|
||||
|
||||
console.print(table)
|
||||
150
app/utils/file_helpers.py
Normal file
150
app/utils/file_helpers.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Security-critical file operation helpers with path sandboxing."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PathSecurityError(Exception):
|
||||
"""Raised when a file path escapes the allowed workspace root."""
|
||||
|
||||
|
||||
class FileSizeError(Exception):
|
||||
"""Raised when a file exceeds the configured size limit."""
|
||||
|
||||
|
||||
class BinaryFileError(Exception):
|
||||
"""Raised when an operation is attempted on a binary file."""
|
||||
|
||||
|
||||
def resolve_safe_path(path: str | Path, workspace_root: Path) -> Path:
|
||||
"""Resolve a path and verify it is within the workspace root.
|
||||
|
||||
Args:
|
||||
path: The path to resolve (absolute or relative to workspace_root).
|
||||
workspace_root: The allowed root directory (must be absolute).
|
||||
|
||||
Returns:
|
||||
The resolved absolute path.
|
||||
|
||||
Raises:
|
||||
PathSecurityError: If the resolved path is outside workspace_root.
|
||||
"""
|
||||
workspace_root = workspace_root.resolve()
|
||||
resolved = (workspace_root / path).resolve()
|
||||
|
||||
if not resolved.is_relative_to(workspace_root):
|
||||
raise PathSecurityError(
|
||||
f"Path '{path}' resolves to '{resolved}' which is outside "
|
||||
f"workspace root '{workspace_root}'"
|
||||
)
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def is_binary_file(file_path: Path, sample_size: int = 8192) -> bool:
|
||||
"""Detect if a file is binary by checking for null bytes in a sample.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to check.
|
||||
sample_size: Number of bytes to read for detection.
|
||||
|
||||
Returns:
|
||||
True if the file appears to be binary.
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
sample = f.read(sample_size)
|
||||
return b"\x00" in sample
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def check_file_size(file_path: Path, max_size_bytes: int) -> None:
|
||||
"""Verify that a file does not exceed the size limit.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to check.
|
||||
max_size_bytes: Maximum allowed file size in bytes.
|
||||
|
||||
Raises:
|
||||
FileSizeError: If the file exceeds the size limit.
|
||||
FileNotFoundError: If the file does not exist.
|
||||
"""
|
||||
size = file_path.stat().st_size
|
||||
if size > max_size_bytes:
|
||||
raise FileSizeError(
|
||||
f"File '{file_path}' is {size:,} bytes, exceeding the "
|
||||
f"{max_size_bytes:,} byte limit"
|
||||
)
|
||||
|
||||
|
||||
def safe_read_file(
|
||||
path: str | Path,
|
||||
workspace_root: Path,
|
||||
max_size_bytes: int = 1_048_576,
|
||||
check_binary: bool = True,
|
||||
) -> str:
|
||||
"""Safely read a file with path sandboxing, size, and binary checks.
|
||||
|
||||
Args:
|
||||
path: Path to the file (relative to workspace_root or absolute).
|
||||
workspace_root: The allowed root directory.
|
||||
max_size_bytes: Maximum file size to read.
|
||||
check_binary: Whether to reject binary files.
|
||||
|
||||
Returns:
|
||||
The file contents as a string.
|
||||
|
||||
Raises:
|
||||
PathSecurityError: If the path escapes the workspace.
|
||||
FileSizeError: If the file is too large.
|
||||
BinaryFileError: If the file is binary and check_binary is True.
|
||||
FileNotFoundError: If the file does not exist.
|
||||
"""
|
||||
safe_path = resolve_safe_path(path, workspace_root)
|
||||
|
||||
if not safe_path.exists():
|
||||
raise FileNotFoundError(f"File not found: {safe_path}")
|
||||
|
||||
check_file_size(safe_path, max_size_bytes)
|
||||
|
||||
if check_binary and is_binary_file(safe_path):
|
||||
raise BinaryFileError(f"File appears to be binary: {safe_path}")
|
||||
|
||||
return safe_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def safe_write_file(
|
||||
path: str | Path,
|
||||
content: str,
|
||||
workspace_root: Path,
|
||||
max_size_bytes: int = 1_048_576,
|
||||
) -> Path:
|
||||
"""Safely write a file with path sandboxing and size checks.
|
||||
|
||||
Args:
|
||||
path: Path to write to (relative to workspace_root or absolute).
|
||||
content: String content to write.
|
||||
workspace_root: The allowed root directory.
|
||||
max_size_bytes: Maximum allowed content size in bytes.
|
||||
|
||||
Returns:
|
||||
The resolved path that was written to.
|
||||
|
||||
Raises:
|
||||
PathSecurityError: If the path escapes the workspace.
|
||||
FileSizeError: If the content exceeds the size limit.
|
||||
"""
|
||||
safe_path = resolve_safe_path(path, workspace_root)
|
||||
|
||||
content_size = len(content.encode("utf-8"))
|
||||
if content_size > max_size_bytes:
|
||||
raise FileSizeError(
|
||||
f"Content is {content_size:,} bytes, exceeding the "
|
||||
f"{max_size_bytes:,} byte limit"
|
||||
)
|
||||
|
||||
# Ensure parent directory exists
|
||||
safe_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
safe_path.write_text(content, encoding="utf-8")
|
||||
|
||||
return safe_path
|
||||
89
app/utils/logging.py
Normal file
89
app/utils/logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Centralized logging setup: structlog for file logs, Rich for terminal output."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from rich.console import Console
|
||||
|
||||
# Shared Rich console instance for the entire application
|
||||
console = Console()
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_file: Path | None = None,
|
||||
log_level: str = "INFO",
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Configure dual logging: Rich handler for terminal, optional file handler.
|
||||
|
||||
Args:
|
||||
log_file: Optional path to a log file for structured file logging.
|
||||
log_level: Minimum log level (DEBUG, INFO, WARNING, ERROR).
|
||||
verbose: If True, sets log level to DEBUG regardless of log_level.
|
||||
"""
|
||||
effective_level = "DEBUG" if verbose else log_level.upper()
|
||||
console_level = effective_level if verbose else "WARNING"
|
||||
|
||||
# Configure stdlib root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(effective_level)
|
||||
|
||||
# Clear any existing handlers to avoid duplicates on re-init
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Rich handler for terminal output — only warnings+ unless verbose
|
||||
from rich.logging import RichHandler
|
||||
|
||||
rich_handler = RichHandler(
|
||||
console=console,
|
||||
show_time=True,
|
||||
show_path=verbose,
|
||||
markup=True,
|
||||
rich_tracebacks=True,
|
||||
)
|
||||
rich_handler.setLevel(console_level)
|
||||
root_logger.addHandler(rich_handler)
|
||||
|
||||
# Optional file handler for persistent structured logs
|
||||
if log_file is not None:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(str(log_file), encoding="utf-8")
|
||||
file_handler.setLevel(effective_level)
|
||||
file_formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Configure structlog to route through stdlib logging
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
"""Get a named structlog logger.
|
||||
|
||||
Args:
|
||||
name: Logger name, typically __name__ of the calling module.
|
||||
|
||||
Returns:
|
||||
A bound structlog logger instance.
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
93
app/utils/token_counter.py
Normal file
93
app/utils/token_counter.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Approximate token counting for conversation budget management."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.message import Message
|
||||
|
||||
|
||||
class TokenUsage(BaseModel):
|
||||
"""Snapshot of token usage for a single LLM call."""
|
||||
|
||||
prompt_tokens: int = Field(default=0, description="Tokens in the prompt")
|
||||
completion_tokens: int = Field(default=0, description="Tokens in the completion")
|
||||
total_tokens: int = Field(default=0, description="Total tokens used")
|
||||
|
||||
|
||||
class TokenCounter:
|
||||
"""Tracks cumulative token usage with character-based estimation.
|
||||
|
||||
Uses a simple heuristic of ~4 characters per token for estimation.
|
||||
This is intentionally approximate — accurate enough for budget tracking.
|
||||
"""
|
||||
|
||||
CHARS_PER_TOKEN: int = 4
|
||||
|
||||
def __init__(self, budget: int = 32_000) -> None:
|
||||
"""Initialize the token counter.
|
||||
|
||||
Args:
|
||||
budget: Maximum token budget for the conversation.
|
||||
"""
|
||||
self._budget = budget
|
||||
self._cumulative = TokenUsage()
|
||||
|
||||
@property
|
||||
def budget(self) -> int:
|
||||
"""The configured token budget."""
|
||||
return self._budget
|
||||
|
||||
@property
|
||||
def cumulative_usage(self) -> TokenUsage:
|
||||
"""Cumulative token usage across all tracked calls."""
|
||||
return self._cumulative
|
||||
|
||||
@property
|
||||
def remaining_budget(self) -> int:
|
||||
"""Estimated tokens remaining before hitting the budget."""
|
||||
return max(0, self._budget - self._cumulative.total_tokens)
|
||||
|
||||
def estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count for a string using character heuristic.
|
||||
|
||||
Args:
|
||||
text: The text to estimate tokens for.
|
||||
|
||||
Returns:
|
||||
Estimated token count.
|
||||
"""
|
||||
return max(1, len(text) // self.CHARS_PER_TOKEN)
|
||||
|
||||
def estimate_messages_tokens(self, messages: list[Message]) -> int:
|
||||
"""Estimate total tokens for a list of messages.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages.
|
||||
|
||||
Returns:
|
||||
Estimated total token count.
|
||||
"""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
if msg.content:
|
||||
total += self.estimate_tokens(msg.content)
|
||||
if msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
total += self.estimate_tokens(tc.function.name)
|
||||
total += self.estimate_tokens(tc.function.arguments)
|
||||
# Per-message overhead (role, formatting)
|
||||
total += 4
|
||||
return total
|
||||
|
||||
def count_usage(self, usage: TokenUsage) -> None:
|
||||
"""Record token usage from an LLM call.
|
||||
|
||||
Args:
|
||||
usage: Token usage from a single call.
|
||||
"""
|
||||
self._cumulative.prompt_tokens += usage.prompt_tokens
|
||||
self._cumulative.completion_tokens += usage.completion_tokens
|
||||
self._cumulative.total_tokens += usage.total_tokens
|
||||
|
||||
def is_over_budget(self) -> bool:
|
||||
"""Check if cumulative usage has exceeded the token budget."""
|
||||
return self._cumulative.total_tokens >= self._budget
|
||||
Reference in New Issue
Block a user