diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..29a20a6 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +OLLAMA_HOST=http://localhost:11434 +OLLAMA_DEFAULT_MODEL=llama3.2 + +OPENAI_API_KEY= +ANTHROPIC_API_KEY= + +# Filesystem tool: comma-separated allowed root paths +FS_ALLOWED_PATHS=/tmp,/home + +# Terminal tool: comma-separated allowed commands +TERMINAL_ALLOWED_COMMANDS=ls,cat,echo,pwd,git,python3,pip + +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b22c1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +.env +.mypy_cache/ +.ruff_cache/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md index e7eee65..4f29592 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,72 @@ navi-1 =============== + +Модульная агентная система с REST API и WebSocket. + +## Запуск + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" + +cp .env.example .env # при необходимости настрой + +uvicorn navi.main:app --reload +``` + +API доступно по адресу `http://localhost:8000`. +Swagger UI: `http://localhost:8000/docs` + +## Быстрый старт (REST) + +```bash +# 1. Создать сессию с профилем "secretary" +curl -X POST http://localhost:8000/sessions \ + -H "Content-Type: application/json" \ + -d '{"profile_id": "secretary"}' + +# 2. Отправить сообщение (замени ) +curl -X POST http://localhost:8000/sessions//messages \ + -H "Content-Type: application/json" \ + -d '{"content": "Найди последние новости про Python 3.13"}' +``` + +## WebSocket + +``` +ws://localhost:8000/ws/sessions/ +``` + +Клиент отправляет: `{"type": "message", "content": "..."}` +Сервер стримит: `stream_start` → `tool_call*` → `stream_delta*` → `stream_end` + +## Структура + +``` +navi/ +├── main.py # FastAPI app +├── config.py # настройки через .env +├── exceptions.py # доменные исключения +├── llm/ # LLM бекенды (ollama, openai stub) +├── tools/ # инструменты (web_search, filesystem, http, code, terminal) +├── profiles/ # профили агентов (smart_home, server_admin, secretary) +├── core/ # агент, реестры, сессии +└── api/ # роуты и WebSocket +``` + +## Профили + +| ID | Описание | Инструменты | +|----|----------|-------------| +| `smart_home` | Умный дом / Home Assistant | http, filesystem, code, terminal | +| `server_admin` | Администрирование серверов | terminal, filesystem, http, web_search | +| `secretary` | Личный секретарь | web_search, http, filesystem, code | + +## Расширение + +**Новый профиль** — создай `navi/profiles/my_profile.py`, добавь в `ALL_PROFILES` в `__init__.py`. + +**Новый инструмент** — реализуй `Tool` из `navi/tools/base.py`, зарегистрируй в `navi/core/registry.py`. + +**Новый LLM бекенд** — реализуй `LLMBackend` из `navi/llm/base.py`, зарегистрируй в `navi/core/registry.py`. diff --git a/navi/__init__.py b/navi/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/navi/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/navi/api/__init__.py b/navi/api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/navi/api/__init__.py diff --git a/navi/api/deps.py b/navi/api/deps.py new file mode 100644 index 0000000..8c4ae30 --- /dev/null +++ b/navi/api/deps.py @@ -0,0 +1,49 @@ +"""FastAPI dependency injection — provides shared singletons to route handlers.""" + +from functools import lru_cache +from typing import Annotated + +from fastapi import Depends + +from navi.core import ( + Agent, + BackendRegistry, + InMemorySessionStore, + ProfileRegistry, + SessionStore, + ToolRegistry, + build_default_registries, +) + + +@lru_cache +def _registries() -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry]: + return build_default_registries() + + +def get_tool_registry() -> ToolRegistry: + return _registries()[0] + + +def get_profile_registry() -> ProfileRegistry: + return _registries()[1] + + +def get_backend_registry() -> BackendRegistry: + return _registries()[2] + + +_session_store = InMemorySessionStore() + + +def get_session_store() -> SessionStore: + return _session_store + + +def get_agent( + session_store: Annotated[SessionStore, Depends(get_session_store)], + profile_registry: Annotated[ProfileRegistry, Depends(get_profile_registry)], + tool_registry: Annotated[ToolRegistry, Depends(get_tool_registry)], + backend_registry: Annotated[BackendRegistry, Depends(get_backend_registry)], +) -> Agent: + return Agent(session_store, profile_registry, tool_registry, backend_registry) diff --git a/navi/api/routes/__init__.py b/navi/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/navi/api/routes/__init__.py diff --git a/navi/api/routes/agents.py b/navi/api/routes/agents.py new file mode 100644 index 0000000..8e94359 --- /dev/null +++ b/navi/api/routes/agents.py @@ -0,0 +1,37 @@ +"""Endpoints for listing available profiles and tools.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from navi.api.deps import get_profile_registry, get_tool_registry +from navi.core import ProfileRegistry, ToolRegistry + +router = APIRouter(prefix="/agents", tags=["agents"]) + + +@router.get("/profiles") +async def list_profiles( + profiles: Annotated[ProfileRegistry, Depends(get_profile_registry)], +) -> list[dict]: + return [ + { + "id": p.id, + "name": p.name, + "description": p.description, + "enabled_tools": p.enabled_tools, + "llm_backend": p.llm_backend, + "model": p.model, + } + for p in profiles.all() + ] + + +@router.get("/tools") +async def list_tools( + tools: Annotated[ToolRegistry, Depends(get_tool_registry)], +) -> list[dict]: + return [ + {"name": t.name, "description": t.description} + for t in tools.all() + ] diff --git a/navi/api/routes/health.py b/navi/api/routes/health.py new file mode 100644 index 0000000..794a09f --- /dev/null +++ b/navi/api/routes/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +async def health() -> dict: + return {"status": "ok"} diff --git a/navi/api/routes/messages.py b/navi/api/routes/messages.py new file mode 100644 index 0000000..082405c --- /dev/null +++ b/navi/api/routes/messages.py @@ -0,0 +1,33 @@ +"""REST endpoint for sending a message to an agent (non-streaming).""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from navi.api.deps import get_agent +from navi.core import Agent +from navi.exceptions import MaxIterationsReached, NaviError, SessionNotFound + +router = APIRouter(prefix="/sessions", tags=["messages"]) + + +class SendMessageRequest(BaseModel): + content: str + + +@router.post("/{session_id}/messages") +async def send_message( + session_id: str, + body: SendMessageRequest, + agent: Annotated[Agent, Depends(get_agent)], +) -> dict: + try: + reply = await agent.run(session_id, body.content) + return {"role": "assistant", "content": reply} + except SessionNotFound: + raise HTTPException(status_code=404, detail="Session not found") + except MaxIterationsReached as e: + raise HTTPException(status_code=500, detail=str(e)) + except NaviError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py new file mode 100644 index 0000000..6ff9d4f --- /dev/null +++ b/navi/api/routes/sessions.py @@ -0,0 +1,79 @@ +"""Session management endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from navi.api.deps import get_profile_registry, get_session_store +from navi.core import ProfileRegistry, SessionStore +from navi.exceptions import ProfileNotFound + +router = APIRouter(prefix="/sessions", tags=["sessions"]) + + +class CreateSessionRequest(BaseModel): + profile_id: str + + +@router.post("", status_code=201) +async def create_session( + body: CreateSessionRequest, + store: Annotated[SessionStore, Depends(get_session_store)], + profiles: Annotated[ProfileRegistry, Depends(get_profile_registry)], +) -> dict: + try: + profiles.get(body.profile_id) + except ProfileNotFound: + raise HTTPException(status_code=404, detail=f"Profile '{body.profile_id}' not found") + + session = await store.create(body.profile_id) + return { + "session_id": session.id, + "profile_id": session.profile_id, + "created_at": session.created_at.isoformat(), + } + + +@router.get("") +async def list_sessions( + store: Annotated[SessionStore, Depends(get_session_store)], +) -> list[dict]: + sessions = await store.list_all() + return [ + { + "session_id": s.id, + "profile_id": s.profile_id, + "message_count": len(s.messages), + "created_at": s.created_at.isoformat(), + "last_active": s.last_active.isoformat(), + } + for s in sessions + ] + + +@router.get("/{session_id}") +async def get_session( + session_id: str, + store: Annotated[SessionStore, Depends(get_session_store)], +) -> dict: + session = await store.get(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + return { + "session_id": session.id, + "profile_id": session.profile_id, + "messages": [m.model_dump(exclude_none=True) for m in session.messages], + "created_at": session.created_at.isoformat(), + "last_active": session.last_active.isoformat(), + } + + +@router.delete("/{session_id}", status_code=204) +async def delete_session( + session_id: str, + store: Annotated[SessionStore, Depends(get_session_store)], +) -> None: + deleted = await store.delete(session_id) + if not deleted: + raise HTTPException(status_code=404, detail="Session not found") diff --git a/navi/api/websocket.py b/navi/api/websocket.py new file mode 100644 index 0000000..117ce58 --- /dev/null +++ b/navi/api/websocket.py @@ -0,0 +1,88 @@ +""" +WebSocket endpoint for streaming agent responses. + +Protocol (client -> server): + {"type": "message", "content": "..."} + +Protocol (server -> client): + {"type": "stream_start"} + {"type": "stream_delta", "delta": "..."} # text chunk + {"type": "tool_call", "tool": "...", "args": {...}, "result": "...", "success": bool} + {"type": "stream_end", "content": "..."} # full assembled response + {"type": "error", "message": "..."} +""" + +import json + +import structlog +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from navi.api.deps import get_agent, get_session_store +from navi.core import Agent, InMemorySessionStore, StreamEnd, TextDelta, ToolEvent +from navi.exceptions import MaxIterationsReached, NaviError, SessionNotFound + +router = APIRouter(tags=["websocket"]) +log = structlog.get_logger() + + +@router.websocket("/ws/sessions/{session_id}") +async def websocket_session(session_id: str, websocket: WebSocket) -> None: + session_store = get_session_store() + + # Validate session exists before accepting + session = await session_store.get(session_id) + if session is None: + await websocket.close(code=4004, reason="Session not found") + return + + await websocket.accept() + log.info("ws.connected", session_id=session_id) + + # Build agent (can't use FastAPI Depends inside WebSocket directly) + from navi.api.deps import _registries + tools, profiles, backends = _registries() + agent = Agent(session_store, profiles, tools, backends) + + try: + while True: + raw = await websocket.receive_text() + try: + data = json.loads(raw) + except json.JSONDecodeError: + await websocket.send_json({"type": "error", "message": "Invalid JSON"}) + continue + + if data.get("type") != "message" or not data.get("content"): + await websocket.send_json({"type": "error", "message": "Expected {type: 'message', content: '...'}"}) + continue + + user_content = data["content"] + await websocket.send_json({"type": "stream_start"}) + + try: + async for event in agent.run_stream(session_id, user_content): + if isinstance(event, TextDelta): + await websocket.send_json({"type": "stream_delta", "delta": event.delta}) + elif isinstance(event, ToolEvent): + await websocket.send_json({ + "type": "tool_call", + "tool": event.tool_name, + "args": event.arguments, + "result": event.result, + "success": event.success, + }) + elif isinstance(event, StreamEnd): + await websocket.send_json({"type": "stream_end", "content": event.full_content}) + + except SessionNotFound: + await websocket.send_json({"type": "error", "message": "Session not found"}) + except MaxIterationsReached as e: + await websocket.send_json({"type": "error", "message": str(e)}) + except NaviError as e: + await websocket.send_json({"type": "error", "message": str(e)}) + except Exception as e: + log.exception("ws.agent_error", session_id=session_id) + await websocket.send_json({"type": "error", "message": f"Internal error: {e}"}) + + except WebSocketDisconnect: + log.info("ws.disconnected", session_id=session_id) diff --git a/navi/config.py b/navi/config.py new file mode 100644 index 0000000..406d73d --- /dev/null +++ b/navi/config.py @@ -0,0 +1,27 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + ollama_host: str = "http://localhost:11434" + ollama_default_model: str = "gemma4:e2b-it-q4_K_M" + + openai_api_key: str = "" + anthropic_api_key: str = "" + + fs_allowed_paths: str = "/tmp" + terminal_allowed_commands: str = "ls,cat,echo,pwd,git,python3" + + log_level: str = "INFO" + + @property + def fs_allowed_paths_list(self) -> list[str]: + return [p.strip() for p in self.fs_allowed_paths.split(",") if p.strip()] + + @property + def terminal_allowed_commands_list(self) -> list[str]: + return [c.strip() for c in self.terminal_allowed_commands.split(",") if c.strip()] + + +settings = Settings() diff --git a/navi/core/__init__.py b/navi/core/__init__.py new file mode 100644 index 0000000..f5079ab --- /dev/null +++ b/navi/core/__init__.py @@ -0,0 +1,18 @@ +from .agent import Agent, AgentEvent, StreamEnd, TextDelta, ToolEvent +from .registry import BackendRegistry, ProfileRegistry, ToolRegistry, build_default_registries +from .session import InMemorySessionStore, Session, SessionStore + +__all__ = [ + "Agent", + "AgentEvent", + "StreamEnd", + "TextDelta", + "ToolEvent", + "BackendRegistry", + "ProfileRegistry", + "ToolRegistry", + "build_default_registries", + "Session", + "SessionStore", + "InMemorySessionStore", +] diff --git a/navi/core/agent.py b/navi/core/agent.py new file mode 100644 index 0000000..6ba7582 --- /dev/null +++ b/navi/core/agent.py @@ -0,0 +1,245 @@ +""" +Agent: the tool-calling loop. + +Flow: +1. Receive user message, load session + profile +2. Build tool schemas from profile's enabled_tools +3. Loop (up to max_iterations): + a. Call LLM with current messages + tool schemas + b. If finish_reason == "stop" -> done, return content + c. If finish_reason == "tool_calls" -> execute tools concurrently, append results, continue +4. Final streaming path: use llm.stream() to yield text deltas to WebSocket clients + +For multi-agent extension: instantiate multiple Agent objects with different profiles. +An Orchestrator (core/orchestrator.py) dispatches tasks to worker agents via asyncio Queues. +""" + +import asyncio +import json +from dataclasses import dataclass +from typing import AsyncGenerator + +import structlog + +from navi.exceptions import MaxIterationsReached, SessionNotFound +from navi.llm.base import LLMBackend, Message, ToolCallRequest +from navi.tools.base import Tool + +from .registry import BackendRegistry, ProfileRegistry, ToolRegistry +from .session import SessionStore + +log = structlog.get_logger() + + +@dataclass +class ToolEvent: + """Emitted during streaming to inform the client about tool activity.""" + + tool_name: str + arguments: dict + result: str + success: bool + + +@dataclass +class TextDelta: + """A chunk of text from the streaming LLM response.""" + + delta: str + + +@dataclass +class StreamEnd: + """Marks the end of the streaming response.""" + + full_content: str + + +AgentEvent = ToolEvent | TextDelta | StreamEnd + + +class Agent: + def __init__( + self, + session_store: SessionStore, + profile_registry: ProfileRegistry, + tool_registry: ToolRegistry, + backend_registry: BackendRegistry, + ) -> None: + self._sessions = session_store + self._profiles = profile_registry + self._tools = tool_registry + self._backends = backend_registry + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + async def run(self, session_id: str, user_message: str) -> str: + """Non-streaming: run the full tool-calling loop and return the final text.""" + session = await self._sessions.get(session_id) + if session is None: + raise SessionNotFound(session_id) + + profile = self._profiles.get(session.profile_id) + tools = self._tool_list(profile.enabled_tools) + tool_schemas = [t.schema() for t in tools] + llm = self._get_backend(profile.llm_backend, profile.model) + + # Inject system prompt on first message + if not session.messages: + session.messages.append(Message(role="system", content=profile.system_prompt)) + + session.messages.append(Message(role="user", content=user_message)) + + for iteration in range(profile.max_iterations): + log.debug("agent.iteration", session_id=session_id, iteration=iteration) + response = await llm.complete( + session.messages, + tools=tool_schemas if tools else None, + temperature=profile.temperature, + ) + + if response.finish_reason == "stop" or not response.tool_calls: + content = response.content or "" + session.messages.append(Message(role="assistant", content=content)) + await self._sessions.save(session) + return content + + # Tool calls turn + assistant_msg = Message( + role="assistant", + content=response.content, + tool_calls=response.tool_calls, + ) + session.messages.append(assistant_msg) + + tool_results = await self._execute_tool_calls(response.tool_calls, tools) + session.messages.extend(tool_results) + + await self._sessions.save(session) + raise MaxIterationsReached(profile.max_iterations) + + async def run_stream( + self, session_id: str, user_message: str + ) -> AsyncGenerator[AgentEvent, None]: + """ + Streaming variant. Yields AgentEvent objects: + - ToolEvent: when a tool is called and its result arrives + - TextDelta: each text chunk from the final LLM response + - StreamEnd: final event with the full assembled content + """ + session = await self._sessions.get(session_id) + if session is None: + raise SessionNotFound(session_id) + + profile = self._profiles.get(session.profile_id) + tools = self._tool_list(profile.enabled_tools) + tool_schemas = [t.schema() for t in tools] + llm = self._get_backend(profile.llm_backend, profile.model) + + if not session.messages: + session.messages.append(Message(role="system", content=profile.system_prompt)) + + session.messages.append(Message(role="user", content=user_message)) + + # Tool-calling loop (non-streaming) + for iteration in range(profile.max_iterations): + response = await llm.complete( + session.messages, + tools=tool_schemas if tools else None, + temperature=profile.temperature, + ) + + if response.finish_reason == "stop" or not response.tool_calls: + # Switch to streaming for the final text response + # Re-use the already-received content, stream it as one delta + final_messages = session.messages.copy() + accumulated = "" + + async for chunk in llm.stream(final_messages, temperature=profile.temperature): + if chunk.delta: + accumulated += chunk.delta + yield TextDelta(delta=chunk.delta) + + session.messages.append(Message(role="assistant", content=accumulated)) + await self._sessions.save(session) + yield StreamEnd(full_content=accumulated) + return + + # Tool calls: emit events, execute, continue loop + assistant_msg = Message( + role="assistant", + content=response.content, + tool_calls=response.tool_calls, + ) + session.messages.append(assistant_msg) + + tool_results_msgs = await self._execute_tool_calls_streaming( + response.tool_calls, tools + ) + for event, msg in tool_results_msgs: + yield event + session.messages.append(msg) + + await self._sessions.save(session) + raise MaxIterationsReached(profile.max_iterations) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _tool_list(self, enabled: list[str]) -> list[Tool]: + return self._tools.resolve(enabled) + + def _get_backend(self, backend_key: str, model: str) -> LLMBackend: + return self._backends.get(backend_key, model) + + async def _execute_tool_calls( + self, tool_calls: list[ToolCallRequest], tools: list[Tool] + ) -> list[Message]: + tool_map = {t.name: t for t in tools} + + async def _run_one(tc: ToolCallRequest) -> Message: + tool = tool_map.get(tc.name) + if tool is None: + content = f"Error: tool '{tc.name}' not found." + else: + log.info("tool.execute", tool=tc.name, args=tc.arguments) + result = await tool.execute(tc.arguments) + content = result.to_message_content() + return Message( + role="tool", + content=content, + tool_call_id=tc.id, + name=tc.name, + ) + + return await asyncio.gather(*[_run_one(tc) for tc in tool_calls]) + + async def _execute_tool_calls_streaming( + self, tool_calls: list[ToolCallRequest], tools: list[Tool] + ) -> list[tuple[ToolEvent, Message]]: + tool_map = {t.name: t for t in tools} + + async def _run_one(tc: ToolCallRequest) -> tuple[ToolEvent, Message]: + tool = tool_map.get(tc.name) + if tool is None: + content = f"Error: tool '{tc.name}' not found." + event = ToolEvent( + tool_name=tc.name, arguments=tc.arguments, result=content, success=False + ) + else: + log.info("tool.execute", tool=tc.name, args=tc.arguments) + result = await tool.execute(tc.arguments) + content = result.to_message_content() + event = ToolEvent( + tool_name=tc.name, + arguments=tc.arguments, + result=content, + success=result.success, + ) + msg = Message(role="tool", content=content, tool_call_id=tc.id, name=tc.name) + return event, msg + + return await asyncio.gather(*[_run_one(tc) for tc in tool_calls]) diff --git a/navi/core/orchestrator.py b/navi/core/orchestrator.py new file mode 100644 index 0000000..00094d9 --- /dev/null +++ b/navi/core/orchestrator.py @@ -0,0 +1,23 @@ +""" +Orchestrator stub — foundation for multi-agent scenarios. + +When multi-agent support is needed: +1. Implement OrchestratorAgent that decomposes tasks into subtasks +2. Each subtask is dispatched to a worker Agent with a specialized profile +3. Workers communicate results via asyncio.Queue (local) or Redis Pub/Sub (distributed) +4. Orchestrator aggregates results and produces a final response + +The existing Agent class requires no modification — orchestration is purely additive. +""" + +from __future__ import annotations + + +class Orchestrator: + """Placeholder for future multi-agent orchestration.""" + + def __init__(self) -> None: + raise NotImplementedError( + "Multi-agent orchestration is not yet implemented. " + "Use Agent directly for single-agent scenarios." + ) diff --git a/navi/core/registry.py b/navi/core/registry.py new file mode 100644 index 0000000..7f4c75c --- /dev/null +++ b/navi/core/registry.py @@ -0,0 +1,94 @@ +"""Registries for tools, profiles, and LLM backends.""" + +from navi.config import settings +from navi.exceptions import ProfileNotFound, ToolNotFound +from navi.llm.base import LLMBackend +from navi.llm.ollama import OllamaBackend +from navi.profiles import ALL_PROFILES +from navi.profiles.base import AgentProfile +from navi.tools import ( + CodeExecTool, + FilesystemTool, + HttpRequestTool, + TerminalTool, + Tool, + WebSearchTool, +) + + +class ToolRegistry: + def __init__(self) -> None: + self._tools: dict[str, Tool] = {} + + def register(self, tool: Tool) -> None: + self._tools[tool.name] = tool + + def get(self, name: str) -> Tool: + if name not in self._tools: + raise ToolNotFound(name) + return self._tools[name] + + def resolve(self, names: list[str]) -> list[Tool]: + return [self.get(n) for n in names] + + def all(self) -> list[Tool]: + return list(self._tools.values()) + + +class ProfileRegistry: + def __init__(self) -> None: + self._profiles: dict[str, AgentProfile] = {} + + def register(self, profile: AgentProfile) -> None: + self._profiles[profile.id] = profile + + def get(self, profile_id: str) -> AgentProfile: + if profile_id not in self._profiles: + raise ProfileNotFound(profile_id) + return self._profiles[profile_id] + + def all(self) -> list[AgentProfile]: + return list(self._profiles.values()) + + +class BackendRegistry: + def __init__(self) -> None: + self._backends: dict[str, LLMBackend] = {} + + def register(self, key: str, backend: LLMBackend) -> None: + self._backends[key] = backend + + def get(self, key: str, model: str | None = None) -> LLMBackend: + backend = self._backends.get(key) + if backend is None: + raise KeyError(f"LLM backend '{key}' not registered") + return backend + + def all_keys(self) -> list[str]: + return list(self._backends.keys()) + + +def build_default_registries() -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry]: + """Build and populate registries with all built-in components.""" + + tools = ToolRegistry() + tools.register(WebSearchTool()) + tools.register(FilesystemTool()) + tools.register(HttpRequestTool()) + tools.register(CodeExecTool()) + tools.register(TerminalTool()) + + profiles = ProfileRegistry() + for p in ALL_PROFILES: + profiles.register(p) + + backends = BackendRegistry() + backends.register( + "ollama", + OllamaBackend( + model=settings.ollama_default_model, + host=settings.ollama_host, + ), + ) + + return tools, profiles, backends diff --git a/navi/core/session.py b/navi/core/session.py new file mode 100644 index 0000000..9a703a5 --- /dev/null +++ b/navi/core/session.py @@ -0,0 +1,60 @@ +"""Session model and in-memory session store.""" + +import uuid +from abc import ABC, abstractmethod +from datetime import datetime + +from pydantic import BaseModel, Field + +from navi.llm.base import Message + + +class Session(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + profile_id: str + messages: list[Message] = Field(default_factory=list) + created_at: datetime = Field(default_factory=datetime.utcnow) + last_active: datetime = Field(default_factory=datetime.utcnow) + + +class SessionStore(ABC): + @abstractmethod + async def create(self, profile_id: str) -> Session: ... + + @abstractmethod + async def get(self, session_id: str) -> Session | None: ... + + @abstractmethod + async def save(self, session: Session) -> None: ... + + @abstractmethod + async def list_all(self) -> list[Session]: ... + + @abstractmethod + async def delete(self, session_id: str) -> bool: ... + + +class InMemorySessionStore(SessionStore): + def __init__(self) -> None: + self._sessions: dict[str, Session] = {} + + async def create(self, profile_id: str) -> Session: + session = Session(profile_id=profile_id) + self._sessions[session.id] = session + return session + + async def get(self, session_id: str) -> Session | None: + return self._sessions.get(session_id) + + async def save(self, session: Session) -> None: + session.last_active = datetime.utcnow() + self._sessions[session.id] = session + + async def list_all(self) -> list[Session]: + return list(self._sessions.values()) + + async def delete(self, session_id: str) -> bool: + if session_id in self._sessions: + del self._sessions[session_id] + return True + return False diff --git a/navi/exceptions.py b/navi/exceptions.py new file mode 100644 index 0000000..c029298 --- /dev/null +++ b/navi/exceptions.py @@ -0,0 +1,36 @@ +class NaviError(Exception): + """Base exception for all navi errors.""" + + +class SessionNotFound(NaviError): + def __init__(self, session_id: str): + super().__init__(f"Session not found: {session_id}") + self.session_id = session_id + + +class ProfileNotFound(NaviError): + def __init__(self, profile_id: str): + super().__init__(f"Profile not found: {profile_id}") + self.profile_id = profile_id + + +class ToolNotFound(NaviError): + def __init__(self, tool_name: str): + super().__init__(f"Tool not found: {tool_name}") + self.tool_name = tool_name + + +class ToolExecutionError(NaviError): + def __init__(self, tool_name: str, reason: str): + super().__init__(f"Tool '{tool_name}' failed: {reason}") + self.tool_name = tool_name + + +class LLMBackendError(NaviError): + pass + + +class MaxIterationsReached(NaviError): + def __init__(self, limit: int): + super().__init__(f"Agent reached max iterations limit ({limit})") + self.limit = limit diff --git a/navi/llm/__init__.py b/navi/llm/__init__.py new file mode 100644 index 0000000..4a78f78 --- /dev/null +++ b/navi/llm/__init__.py @@ -0,0 +1,3 @@ +from .base import LLMBackend, LLMChunk, LLMResponse, Message, ToolCallRequest, ToolSchema + +__all__ = ["LLMBackend", "LLMChunk", "LLMResponse", "Message", "ToolCallRequest", "ToolSchema"] diff --git a/navi/llm/base.py b/navi/llm/base.py new file mode 100644 index 0000000..d9695f8 --- /dev/null +++ b/navi/llm/base.py @@ -0,0 +1,74 @@ +""" +Canonical types and abstract base class for LLM backends. + +All backends translate their native wire format into these types. +Message format follows the OpenAI convention (compatible with Ollama and Anthropic adapters). +""" + +from abc import ABC, abstractmethod +from typing import AsyncGenerator, Literal + +from pydantic import BaseModel + + +class ToolCallRequest(BaseModel): + """A tool call requested by the LLM.""" + + id: str + name: str + arguments: dict + + +class ToolSchema(BaseModel): + """Tool definition sent to the LLM.""" + + type: str = "function" + function: dict # {name: str, description: str, parameters: JSON Schema} + + +class Message(BaseModel): + """Canonical message format (OpenAI-compatible).""" + + role: Literal["system", "user", "assistant", "tool"] + content: str | None = None + # set by assistant when requesting tool calls + tool_calls: list[ToolCallRequest] | None = None + # set on tool result messages + tool_call_id: str | None = None + name: str | None = None # tool name on tool result messages + + +class LLMResponse(BaseModel): + """Non-streaming response from an LLM backend.""" + + content: str | None + tool_calls: list[ToolCallRequest] | None + finish_reason: str # "stop" | "tool_calls" | "length" + + +class LLMChunk(BaseModel): + """A single chunk from a streaming LLM response.""" + + delta: str | None = None + finish_reason: str | None = None # "stop" | "length"; None while streaming + + +class LLMBackend(ABC): + """Abstract base class for LLM backends.""" + + @abstractmethod + async def complete( + self, + messages: list[Message], + tools: list[ToolSchema] | None = None, + temperature: float = 0.7, + ) -> LLMResponse: + """Single-shot completion. Used in the agent tool-calling loop.""" + + @abstractmethod + async def stream( + self, + messages: list[Message], + temperature: float = 0.7, + ) -> AsyncGenerator[LLMChunk, None]: + """Streaming text completion (no tool calling). Used for final response streaming.""" diff --git a/navi/llm/ollama.py b/navi/llm/ollama.py new file mode 100644 index 0000000..1aeef02 --- /dev/null +++ b/navi/llm/ollama.py @@ -0,0 +1,93 @@ +"""Ollama LLM backend.""" + +import uuid +from typing import AsyncGenerator + +import ollama as ollama_client + +from navi.exceptions import LLMBackendError + +from .base import LLMBackend, LLMChunk, LLMResponse, Message, ToolCallRequest, ToolSchema + + +def _to_ollama_messages(messages: list[Message]) -> list[dict]: + result = [] + for m in messages: + msg: dict = {"role": m.role, "content": m.content or ""} + if m.tool_calls: + msg["tool_calls"] = [ + {"function": {"name": tc.name, "arguments": tc.arguments}} + for tc in m.tool_calls + ] + if m.tool_call_id: + # Ollama uses role="tool" with content + pass + result.append(msg) + return result + + +def _to_ollama_tools(tools: list[ToolSchema]) -> list[dict]: + return [t.model_dump() for t in tools] + + +class OllamaBackend(LLMBackend): + def __init__(self, model: str, host: str = "http://localhost:11434"): + self.model = model + self._client = ollama_client.AsyncClient(host=host) + + async def complete( + self, + messages: list[Message], + tools: list[ToolSchema] | None = None, + temperature: float = 0.7, + ) -> LLMResponse: + try: + kwargs: dict = { + "model": self.model, + "messages": _to_ollama_messages(messages), + "options": {"temperature": temperature}, + "stream": False, + } + if tools: + kwargs["tools"] = _to_ollama_tools(tools) + + response = await self._client.chat(**kwargs) + msg = response.message + + tool_calls = None + if msg.tool_calls: + tool_calls = [ + ToolCallRequest( + id=str(uuid.uuid4()), + name=tc.function.name, + arguments=dict(tc.function.arguments), + ) + for tc in msg.tool_calls + ] + + finish_reason = "tool_calls" if tool_calls else "stop" + return LLMResponse( + content=msg.content or None, + tool_calls=tool_calls, + finish_reason=finish_reason, + ) + except Exception as e: + raise LLMBackendError(str(e)) from e + + async def stream( + self, + messages: list[Message], + temperature: float = 0.7, + ) -> AsyncGenerator[LLMChunk, None]: + try: + async for chunk in await self._client.chat( + model=self.model, + messages=_to_ollama_messages(messages), + options={"temperature": temperature}, + stream=True, + ): + delta = chunk.message.content or None + finish_reason = "stop" if chunk.done else None + yield LLMChunk(delta=delta, finish_reason=finish_reason) + except Exception as e: + raise LLMBackendError(str(e)) from e diff --git a/navi/llm/openai_backend.py b/navi/llm/openai_backend.py new file mode 100644 index 0000000..72dc338 --- /dev/null +++ b/navi/llm/openai_backend.py @@ -0,0 +1,33 @@ +"""OpenAI (and OpenAI-compatible) LLM backend. Stub — implement when needed.""" + +from typing import AsyncGenerator + +from .base import LLMBackend, LLMChunk, LLMResponse, Message, ToolSchema + + +class OpenAIBackend(LLMBackend): + """ + Supports OpenAI and any OpenAI-compatible endpoint (vLLM, LM Studio, etc.). + Set base_url to point at a compatible server. + """ + + def __init__(self, model: str, api_key: str, base_url: str | None = None): + self.model = model + self._api_key = api_key + self._base_url = base_url + + async def complete( + self, + messages: list[Message], + tools: list[ToolSchema] | None = None, + temperature: float = 0.7, + ) -> LLMResponse: + raise NotImplementedError("OpenAI backend not yet implemented") + + async def stream( + self, + messages: list[Message], + temperature: float = 0.7, + ) -> AsyncGenerator[LLMChunk, None]: + raise NotImplementedError("OpenAI backend not yet implemented") + yield # makes this a generator diff --git a/navi/main.py b/navi/main.py new file mode 100644 index 0000000..0b4dd59 --- /dev/null +++ b/navi/main.py @@ -0,0 +1,26 @@ +"""FastAPI application entry point.""" + +import structlog +from fastapi import FastAPI + +from navi.api.routes import agents, health, messages, sessions +from navi.api.websocket import router as ws_router +from navi.config import settings + +structlog.configure( + wrapper_class=structlog.make_filtering_bound_logger( + getattr(__import__("logging"), settings.log_level) + ), +) + +app = FastAPI( + title="Navi", + description="Modular agent system — REST API and WebSocket", + version="0.1.0", +) + +app.include_router(health.router) +app.include_router(agents.router) +app.include_router(sessions.router) +app.include_router(messages.router) +app.include_router(ws_router) diff --git a/navi/profiles/__init__.py b/navi/profiles/__init__.py new file mode 100644 index 0000000..65175f8 --- /dev/null +++ b/navi/profiles/__init__.py @@ -0,0 +1,8 @@ +from .base import AgentProfile +from .secretary import secretary +from .server_admin import server_admin +from .smart_home import smart_home + +ALL_PROFILES = [smart_home, server_admin, secretary] + +__all__ = ["AgentProfile", "ALL_PROFILES", "smart_home", "server_admin", "secretary"] diff --git a/navi/profiles/base.py b/navi/profiles/base.py new file mode 100644 index 0000000..f611006 --- /dev/null +++ b/navi/profiles/base.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field + + +@dataclass +class AgentProfile: + """ + Defines a complete agent configuration. + A profile ties together a system prompt, an LLM backend, a model, + and the set of tools the agent is allowed to use. + """ + + id: str + name: str + description: str + system_prompt: str + enabled_tools: list[str] # tool names; resolved by ToolRegistry at runtime + llm_backend: str = "ollama" # backend key, e.g. "ollama", "openai" + model: str = "llama3.2" + max_iterations: int = 10 + temperature: float = 0.7 diff --git a/navi/profiles/secretary.py b/navi/profiles/secretary.py new file mode 100644 index 0000000..9d8456a --- /dev/null +++ b/navi/profiles/secretary.py @@ -0,0 +1,23 @@ +from .base import AgentProfile + +secretary = AgentProfile( + id="secretary", + name="Personal Secretary", + description="General-purpose assistant for research, writing, scheduling, and task management.", + system_prompt="""You are a capable personal secretary. You help with research, +writing, information gathering, and general task assistance. + +You have access to: +- Web search to find current information +- HTTP requests to query external APIs or services +- Filesystem to read and write documents, notes, and files +- Code execution to perform calculations, data processing, or automate tasks + +Be concise and actionable in your responses. When asked to research a topic, +provide a structured summary with sources. When writing documents, match the +requested tone and format. +""", + enabled_tools=["web_search", "http_request", "filesystem", "code_exec"], + model="gemma4:e2b-it-q4_K_M", + temperature=0.7, +) diff --git a/navi/profiles/server_admin.py b/navi/profiles/server_admin.py new file mode 100644 index 0000000..bc0b1b2 --- /dev/null +++ b/navi/profiles/server_admin.py @@ -0,0 +1,25 @@ +from .base import AgentProfile + +server_admin = AgentProfile( + id="server_admin", + name="Server Administrator", + description="Assists with server administration, monitoring, and infrastructure tasks.", + system_prompt="""You are an experienced server administrator assistant. You help with +system monitoring, troubleshooting, configuration management, and infrastructure tasks. + +You have access to: +- Terminal to run system commands (within the allowed command list) +- Filesystem to inspect and edit configuration files +- HTTP requests to query monitoring APIs, health endpoints, or remote services +- Web search to look up documentation or solutions + +Guidelines: +- Always explain what a command will do before running it +- Prefer non-destructive operations; ask for confirmation before anything irreversible +- When troubleshooting, gather information first (logs, status) before making changes +- Document any changes you make +""", + enabled_tools=["terminal", "filesystem", "http_request", "web_search"], + model="gemma4:e2b-it-q4_K_M", + temperature=0.2, +) diff --git a/navi/profiles/smart_home.py b/navi/profiles/smart_home.py new file mode 100644 index 0000000..bd47c0b --- /dev/null +++ b/navi/profiles/smart_home.py @@ -0,0 +1,22 @@ +from .base import AgentProfile + +smart_home = AgentProfile( + id="smart_home", + name="Smart Home Assistant", + description="Manages smart home devices, automation scripts, and Home Assistant.", + system_prompt="""You are a smart home automation assistant. You help manage devices, +write automation scripts, and interact with Home Assistant. + +You have access to: +- HTTP requests to call Home Assistant REST API or local device APIs +- Filesystem to read/write automation scripts and configuration files +- Code execution to generate and test Home Assistant YAML automations or Python scripts +- Terminal for system-level smart home commands + +Always confirm before making irreversible changes to device state or automation configuration. +When writing automations, prefer clear, well-commented YAML. +""", + enabled_tools=["http_request", "filesystem", "code_exec", "terminal"], + model="gemma4:e2b-it-q4_K_M", + temperature=0.3, +) diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py new file mode 100644 index 0000000..6aa059e --- /dev/null +++ b/navi/tools/__init__.py @@ -0,0 +1,16 @@ +from .base import Tool, ToolResult +from .code_exec import CodeExecTool +from .filesystem import FilesystemTool +from .http_request import HttpRequestTool +from .terminal import TerminalTool +from .web_search import WebSearchTool + +__all__ = [ + "Tool", + "ToolResult", + "WebSearchTool", + "FilesystemTool", + "HttpRequestTool", + "CodeExecTool", + "TerminalTool", +] diff --git a/navi/tools/base.py b/navi/tools/base.py new file mode 100644 index 0000000..6692ce8 --- /dev/null +++ b/navi/tools/base.py @@ -0,0 +1,47 @@ +""" +Base class for all tools. + +Each tool is self-describing: name, description, and parameters (JSON Schema). +The schema() method builds the LLM-facing function spec automatically. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + +from navi.llm.base import ToolSchema + + +@dataclass +class ToolResult: + success: bool + output: str # always a string — LLM consumes this + error: str | None = None + metadata: dict = field(default_factory=dict) + + def to_message_content(self) -> str: + if self.success: + return self.output + return f"Error: {self.error}" + + +class Tool(ABC): + """Abstract base for all tools.""" + + # Override in subclasses: + name: str + description: str + parameters: dict # JSON Schema object + + @abstractmethod + async def execute(self, params: dict) -> ToolResult: + """Execute the tool with given parameters.""" + + def schema(self) -> ToolSchema: + return ToolSchema( + type="function", + function={ + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + ) diff --git a/navi/tools/code_exec.py b/navi/tools/code_exec.py new file mode 100644 index 0000000..5cefe5c --- /dev/null +++ b/navi/tools/code_exec.py @@ -0,0 +1,86 @@ +"""Code execution tool — run Python code in a subprocess sandbox.""" + +import asyncio +import sys +import tempfile +from pathlib import Path + +from .base import Tool, ToolResult + +_TIMEOUT = 30 + + +class CodeExecTool(Tool): + name = "code_exec" + description = ( + "Execute Python code and return stdout/stderr. " + "Each execution runs in an isolated subprocess with a fresh interpreter. " + "No persistent state between calls." + ) + parameters = { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python code to execute", + }, + "language": { + "type": "string", + "enum": ["python"], + "description": "Programming language (currently only python)", + "default": "python", + }, + }, + "required": ["code"], + } + + async def execute(self, params: dict) -> ToolResult: + code = params["code"] + language = params.get("language", "python") + + if language != "python": + return ToolResult( + success=False, + output=f"Language '{language}' is not supported.", + error="unsupported_language", + ) + + with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f: + f.write(code) + script_path = f.name + + try: + proc = await asyncio.create_subprocess_exec( + sys.executable, + script_path, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=tempfile.gettempdir(), + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT) + except asyncio.TimeoutError: + proc.kill() + return ToolResult( + success=False, + output=f"Code execution timed out after {_TIMEOUT}s", + error="timeout", + ) + + output_parts = [] + if stdout: + output_parts.append(stdout.decode(errors="replace")) + if stderr: + output_parts.append(f"[stderr]\n{stderr.decode(errors='replace')}") + + success = proc.returncode == 0 + return ToolResult( + success=success, + output="\n".join(output_parts) or "(no output)", + metadata={"returncode": proc.returncode}, + error=None if success else f"Exit code {proc.returncode}", + ) + except Exception as e: + return ToolResult(success=False, output=f"Execution error: {e}", error=str(e)) + finally: + Path(script_path).unlink(missing_ok=True) diff --git a/navi/tools/filesystem.py b/navi/tools/filesystem.py new file mode 100644 index 0000000..83afdc6 --- /dev/null +++ b/navi/tools/filesystem.py @@ -0,0 +1,107 @@ +"""Filesystem tool — read/write/list files within allowed paths.""" + +import os +from pathlib import Path + +from navi.config import settings + +from .base import Tool, ToolResult + +_ALLOWED = [Path(p).resolve() for p in settings.fs_allowed_paths_list] + + +def _check_path(path_str: str) -> Path | None: + """Return resolved Path if it's within an allowed root, else None.""" + try: + p = Path(path_str).resolve() + except Exception: + return None + for allowed in _ALLOWED: + try: + p.relative_to(allowed) + return p + except ValueError: + continue + return None + + +class FilesystemTool(Tool): + name = "filesystem" + description = ( + "Read, write, list, or delete files and directories. " + "Only paths within configured allowed roots are accessible." + ) + parameters = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["read", "write", "list", "delete", "exists"], + "description": "Operation to perform", + }, + "path": {"type": "string", "description": "Absolute or relative file/directory path"}, + "content": { + "type": "string", + "description": "Content to write (required for 'write' action)", + }, + }, + "required": ["action", "path"], + } + + async def execute(self, params: dict) -> ToolResult: + action = params["action"] + raw_path = params["path"] + path = _check_path(raw_path) + + if path is None: + return ToolResult( + success=False, + output=f"Access denied: '{raw_path}' is outside allowed paths.", + error="access_denied", + ) + + try: + match action: + case "read": + if not path.exists(): + return ToolResult(success=False, output=f"File not found: {path}", error="not_found") + content = path.read_text(encoding="utf-8", errors="replace") + return ToolResult(success=True, output=content) + + case "write": + content = params.get("content", "") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return ToolResult(success=True, output=f"Written {len(content)} bytes to {path}") + + case "list": + if not path.exists(): + return ToolResult(success=False, output=f"Path not found: {path}", error="not_found") + if path.is_file(): + return ToolResult(success=True, output=str(path)) + entries = sorted(path.iterdir(), key=lambda e: (e.is_file(), e.name)) + lines = [ + f"{' ' if e.is_file() else 'd '}{e.name}" for e in entries + ] + return ToolResult(success=True, output="\n".join(lines) or "(empty directory)") + + case "delete": + if not path.exists(): + return ToolResult(success=False, output=f"Not found: {path}", error="not_found") + if path.is_dir(): + import shutil + shutil.rmtree(path) + else: + path.unlink() + return ToolResult(success=True, output=f"Deleted: {path}") + + case "exists": + return ToolResult(success=True, output="true" if path.exists() else "false") + + case _: + return ToolResult(success=False, output=f"Unknown action: {action}", error="invalid_action") + + except PermissionError as e: + return ToolResult(success=False, output=f"Permission denied: {e}", error=str(e)) + except Exception as e: + return ToolResult(success=False, output=f"Filesystem error: {e}", error=str(e)) diff --git a/navi/tools/http_request.py b/navi/tools/http_request.py new file mode 100644 index 0000000..16e1e57 --- /dev/null +++ b/navi/tools/http_request.py @@ -0,0 +1,76 @@ +"""HTTP request tool — make outbound HTTP calls.""" + +import json + +import httpx + +from .base import Tool, ToolResult + +_TIMEOUT = 30.0 + + +class HttpRequestTool(Tool): + name = "http_request" + description = ( + "Make an HTTP request to an external URL. " + "Supports GET, POST, PUT, PATCH, DELETE. Returns status code and response body." + ) + parameters = { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], + "description": "HTTP method", + }, + "url": {"type": "string", "description": "Full URL to request"}, + "headers": { + "type": "object", + "description": "Optional HTTP headers as key-value pairs", + }, + "body": { + "type": "object", + "description": "Optional JSON body for POST/PUT/PATCH requests", + }, + "params": { + "type": "object", + "description": "Optional query parameters", + }, + }, + "required": ["method", "url"], + } + + async def execute(self, params: dict) -> ToolResult: + method = params["method"].upper() + url = params["url"] + headers = params.get("headers") or {} + body = params.get("body") + query_params = params.get("params") + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client: + response = await client.request( + method=method, + url=url, + headers=headers, + json=body, + params=query_params, + ) + + # Try to decode as JSON for nicer output + try: + body_repr = json.dumps(response.json(), ensure_ascii=False, indent=2) + except Exception: + body_repr = response.text[:4096] # cap large responses + + output = f"Status: {response.status_code}\n\n{body_repr}" + return ToolResult( + success=response.is_success, + output=output, + metadata={"status_code": response.status_code, "headers": dict(response.headers)}, + error=None if response.is_success else f"HTTP {response.status_code}", + ) + except httpx.TimeoutException: + return ToolResult(success=False, output=f"Request timed out after {_TIMEOUT}s", error="timeout") + except Exception as e: + return ToolResult(success=False, output=f"Request failed: {e}", error=str(e)) diff --git a/navi/tools/terminal.py b/navi/tools/terminal.py new file mode 100644 index 0000000..2711aa4 --- /dev/null +++ b/navi/tools/terminal.py @@ -0,0 +1,93 @@ +"""Terminal tool — run shell commands from an allowlist.""" + +import asyncio +import shlex + +from navi.config import settings + +from .base import Tool, ToolResult + +_TIMEOUT = 30 + + +class TerminalTool(Tool): + name = "terminal" + description = ( + "Execute a shell command. Only commands whose first token is in the allowed list " + "can be run. Returns stdout and stderr." + ) + parameters = { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Shell command to execute", + }, + "working_dir": { + "type": "string", + "description": "Working directory (optional, defaults to /tmp)", + }, + }, + "required": ["command"], + } + + async def execute(self, params: dict) -> ToolResult: + command = params["command"].strip() + working_dir = params.get("working_dir", "/tmp") + + # Safety: check first token against allowlist + try: + tokens = shlex.split(command) + except ValueError as e: + return ToolResult(success=False, output=f"Invalid command syntax: {e}", error=str(e)) + + if not tokens: + return ToolResult(success=False, output="Empty command.", error="empty_command") + + allowed = settings.terminal_allowed_commands_list + if tokens[0] not in allowed: + return ToolResult( + success=False, + output=f"Command '{tokens[0]}' is not in the allowed list: {allowed}", + error="not_allowed", + ) + + try: + proc = await asyncio.create_subprocess_exec( + *tokens, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=working_dir, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT) + except asyncio.TimeoutError: + proc.kill() + return ToolResult( + success=False, + output=f"Command timed out after {_TIMEOUT}s", + error="timeout", + ) + + output_parts = [] + if stdout: + output_parts.append(stdout.decode(errors="replace")) + if stderr: + output_parts.append(f"[stderr]\n{stderr.decode(errors='replace')}") + + success = proc.returncode == 0 + output = "\n".join(output_parts) or "(no output)" + return ToolResult( + success=success, + output=output, + metadata={"returncode": proc.returncode}, + error=None if success else f"Exit code {proc.returncode}", + ) + except FileNotFoundError: + return ToolResult( + success=False, + output=f"Command not found: {tokens[0]}", + error="not_found", + ) + except Exception as e: + return ToolResult(success=False, output=f"Execution error: {e}", error=str(e)) diff --git a/navi/tools/web_search.py b/navi/tools/web_search.py new file mode 100644 index 0000000..02ba461 --- /dev/null +++ b/navi/tools/web_search.py @@ -0,0 +1,41 @@ +"""Web search tool using DuckDuckGo (no API key required).""" + +import json + +from ddgs import DDGS + +from .base import Tool, ToolResult + + +class WebSearchTool(Tool): + name = "web_search" + description = "Search the web for current information. Returns a list of results with title, URL, and snippet." + parameters = { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "max_results": { + "type": "integer", + "description": "Number of results to return (default 5)", + "default": 5, + }, + }, + "required": ["query"], + } + + async def execute(self, params: dict) -> ToolResult: + query = params["query"] + max_results = int(params.get("max_results", 5)) + try: + results = DDGS().text(query, max_results=max_results) + if not results: + return ToolResult(success=True, output="No results found.") + + formatted = [ + f"[{i+1}] {r['title']}\n URL: {r['href']}\n {r['body']}" + for i, r in enumerate(results) + ] + output = "\n\n".join(formatted) + return ToolResult(success=True, output=output, metadata={"results": results}) + except Exception as e: + return ToolResult(success=False, output=f"Search failed: {e}", error=str(e)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7a45402 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "navi" +version = "0.1.0" +description = "Modular agent system with REST API and WebSocket" +requires-python = ">=3.11" + +dependencies = [ + # API + "fastapi>=0.111", + "uvicorn[standard]>=0.29", + + # LLM backends + "ollama>=0.2", + "openai>=1.30", + "anthropic>=0.26", + + # HTTP / tools + "httpx>=0.27", + "ddgs>=1.0", + + # Config + "pydantic>=2.7", + "pydantic-settings>=2.3", + "python-dotenv>=1.0", + + # Utilities + "tenacity>=8.3", + "structlog>=24.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "ruff>=0.4", + "mypy>=1.10", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.pytest.ini_options] +asyncio_mode = "auto"