diff --git a/CLAUDE.md b/CLAUDE.md index 987f26f..9e85019 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,14 @@ Errors isolated per file — one broken file doesn't affect others. Detailed error messages: lists exactly which required definitions are missing. +### Context providers (`navi/context_providers/`, `context_providers/`) +Inject dynamic runtime data as `role="system"` messages on every LLM call (before conversation history). +Module format: `name`, `description`, `global_provider: bool`, `async def get_context() -> str | None`. +`global_provider=True` → injected in all profiles. `False` → opt-in via `context_providers: [...]` in profile config. +Built-in: `public_url` (always injects `PUBLIC_URL` so Navi knows her own address). +Hot-reloaded by `reload_tools`. Navi uses `tool_manual("write_context_provider")` before writing one. +Full reference: `docs/context_providers.md`. + ## Config (`.env`) ``` NAVI_PERSONA="..." # global personality + tool writing rules @@ -140,6 +148,7 @@ | `docs/memory.md` | Long-term memory — facts, extraction, search | | `docs/api.md` | All REST + WS endpoints with request/response schemas | | `docs/config.md` | All `.env` variables with types and defaults | +| `docs/context_providers.md` | Context providers — dynamic system message injection | | `docs/architecture.md` | Component diagram, data flow, registry wiring | `NAVI.md` (project root) is a lightweight hub with the server command, key paths table, and a `filesystem(action="query")` pattern for querying docs at runtime. diff --git a/context_providers/_template.py b/context_providers/_template.py new file mode 100644 index 0000000..92b48ad --- /dev/null +++ b/context_providers/_template.py @@ -0,0 +1,22 @@ +"""Template for user context providers. + +Files starting with '_' are not loaded. Copy this file without the underscore prefix. +Place in context_providers/ at project root. Reload with reload_tools. +""" + +# Required fields +name = "my_provider" +description = "One-line description of what dynamic data this injects." + +# Set to True to inject in ALL profiles automatically (like public_url). +# Set to False (default) to inject only in profiles that list this name +# in their context_providers config field. +global_provider = False + + +async def get_context() -> str | None: + """Return a string to inject as a system message, or None to skip.""" + # Example: return current hostname + # import socket + # return f"[System] Hostname: {socket.gethostname()}" + return None diff --git a/docs/context_providers.md b/docs/context_providers.md new file mode 100644 index 0000000..e673349 --- /dev/null +++ b/docs/context_providers.md @@ -0,0 +1,86 @@ +# Context Providers + +Context providers inject dynamic runtime data as system messages into the LLM context on every call. Unlike tools (which the model calls on demand), providers run automatically before every LLM request. + +## Use cases + +- Server's own public URL (built-in: `public_url`) +- Current hostname or environment name +- Active feature flags +- Short status summaries that should always be visible + +--- + +## How it works + +On every LLM call, `Agent._collect_context_injections(profile)` runs all active providers and returns their output as `role="system"` messages. These are inserted right after the memory summary, before conversation history. + +**Which providers run for a given call:** +1. All providers where `global_provider = True` (always, regardless of profile) +2. Providers listed in the profile's `context_providers` config field + +--- + +## Built-in providers + +| Name | global | Description | +|---|---|---| +| `public_url` | yes | Injects `PUBLIC_URL` from settings so Navi always knows her own address | + +--- + +## User providers + +Drop a `.py` file in `context_providers/` at project root. Call `reload_tools` to activate. + +### Module format + +```python +name = "my_provider" +description = "What this injects." +global_provider = False # True = all profiles, False = opt-in per profile + +async def get_context() -> str | None: + return "[System] ..." # or None to skip +``` + +### Template + +See `context_providers/_template.py` for a copy-paste starting point. + +--- + +## Profile configuration + +Add provider names to `context_providers` in `config.json` to opt in to non-global providers: + +```json +{ + "context_providers": ["hostname", "active_project"] +} +``` + +Default is an empty list — only global providers run. + +--- + +## Reload + +`reload_tools` reloads both user tools and user context providers in one call. Built-in providers (in `navi/context_providers/`) are loaded at startup only. + +--- + +## Writing providers (Navi) + +Use `tool_manual("write_context_provider")` for the full format reference and examples before writing a provider file. + +--- + +## File locations + +| Path | Purpose | +|---|---| +| `navi/context_providers/` | Built-in providers (server code) | +| `context_providers/` | User-written providers (hot-reloadable) | +| `context_providers/_template.py` | Format reference (not loaded) | +| `manuals/write_context_provider.md` | Navi's how-to guide | diff --git a/manuals/write_context_provider.md b/manuals/write_context_provider.md new file mode 100644 index 0000000..3a2fe9a --- /dev/null +++ b/manuals/write_context_provider.md @@ -0,0 +1,68 @@ +# Writing a Context Provider + +Context providers inject dynamic runtime data as system messages into every LLM call. Use them when information is needed frequently and fetching it on-demand every time would be wasteful (current URL, hostname, server status, etc.). + +## When to use a context provider vs a tool + +- **Context provider**: data that is useful on *every* call without the model asking — server address, current environment, active config values. +- **Tool**: data that is needed occasionally on demand — current weather, a file's contents, search results. + +## File format + +Create a file in `context_providers/` at the project root. File name must not start with `_`. + +```python +# context_providers/my_provider.py + +name = "my_provider" +description = "One-line description of what this injects." +global_provider = False # True = injected in ALL profiles automatically + +async def get_context() -> str | None: + """Return a string or None. None means skip silently.""" + return "[System] My info: ..." +``` + +### Required fields +| Field | Type | Description | +|---|---|---| +| `name` | `str` | Unique identifier | +| `description` | `str` | What data this provides | +| `get_context` | `async def` | Returns `str` to inject or `None` to skip | + +### Optional fields +| Field | Type | Default | Description | +|---|---|---|---| +| `global_provider` | `bool` | `False` | `True` = inject in every profile; `False` = only in profiles that list this name in `context_providers` | + +## Enabling per-profile + +For non-global providers, add the name to the profile's `config.json`: +```json +"context_providers": ["my_provider"] +``` + +## Activation + +After writing, call `reload_tools` — it reloads both tools and context providers. The new data will appear in the next LLM call. + +## Example: inject current server hostname + +```python +# context_providers/hostname.py +import socket + +name = "hostname" +description = "Injects the current server hostname." +global_provider = True + +async def get_context() -> str | None: + return f"[System] Server hostname: {socket.gethostname()}" +``` + +## Notes + +- Return value is injected as `role="system"` right after the memory summary, before conversation history. +- Exceptions in `get_context()` are caught and logged — they never crash the agent. +- `global_provider = True` providers are loaded for every profile; no config change needed. +- Built-in providers live in `navi/context_providers/` and cannot be overwritten by user providers. diff --git a/navi/api/deps.py b/navi/api/deps.py index 54f6b15..38e9ec4 100644 --- a/navi/api/deps.py +++ b/navi/api/deps.py @@ -5,6 +5,7 @@ from fastapi import Depends from navi.config import settings +from navi.context_providers._loader import ContextProviderRegistry from navi.core import ( Agent, BackendRegistry, @@ -33,14 +34,14 @@ _memory_store = _make_memory_store() -_registries: tuple[ToolRegistry, ProfileRegistry, BackendRegistry] | None = None +_registries: tuple[ToolRegistry, ProfileRegistry, BackendRegistry, ContextProviderRegistry] | None = None def get_memory_store() -> MemoryStore: return _memory_store -def get_registries() -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry]: +def get_registries() -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry, ContextProviderRegistry]: global _registries if _registries is None: _registries = build_default_registries( @@ -62,6 +63,10 @@ return get_registries()[2] +def get_cp_registry() -> ContextProviderRegistry: + return get_registries()[3] + + _session_store = _make_session_store() _workers: list[Worker] = build_default_workers() @@ -79,6 +84,7 @@ profile_registry: Annotated[ProfileRegistry, Depends(get_profile_registry)], tool_registry: Annotated[ToolRegistry, Depends(get_tool_registry)], backend_registry: Annotated[BackendRegistry, Depends(get_backend_registry)], + cp_registry: Annotated[ContextProviderRegistry, Depends(get_cp_registry)], ) -> Agent: return Agent(session_store, profile_registry, tool_registry, backend_registry, - workers=get_workers(), memory_store=_memory_store) + workers=get_workers(), memory_store=_memory_store, cp_registry=cp_registry) diff --git a/navi/config.py b/navi/config.py index 42dfa27..36bc0a1 100644 --- a/navi/config.py +++ b/navi/config.py @@ -47,6 +47,9 @@ # Directory for user-defined tools (auto-discovered at startup) tools_dir: str = "tools" + # Directory for user-defined context providers (auto-discovered at startup) + context_providers_dir: str = "context_providers" + # Session file uploads session_files_dir: str = "session_files" session_files_max_size_mb: int = 200 diff --git a/navi/context_providers/__init__.py b/navi/context_providers/__init__.py new file mode 100644 index 0000000..5c45db3 --- /dev/null +++ b/navi/context_providers/__init__.py @@ -0,0 +1,3 @@ +from ._loader import ContextProviderRegistry + +__all__ = ["ContextProviderRegistry"] diff --git a/navi/context_providers/_loader.py b/navi/context_providers/_loader.py new file mode 100644 index 0000000..e5e67c1 --- /dev/null +++ b/navi/context_providers/_loader.py @@ -0,0 +1,90 @@ +"""Context provider registry and loader. + +Context providers inject dynamic runtime data into the LLM context on every call. +Each provider is a .py module with: + - name: str + - description: str + - global_provider: bool (True = injected in all profiles regardless of config) + - async def get_context() -> str | None +""" + +import importlib.util +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import structlog + +log = structlog.get_logger() + +_REQUIRED = ("name", "description", "get_context") +_BUILTIN_PREFIX = "navi_cp" +_USER_PREFIX = "user_cp" + + +@dataclass +class CPLoadResult: + loaded: list[str] = field(default_factory=list) + errors: dict[str, str] = field(default_factory=dict) + + +class ContextProviderRegistry: + def __init__(self) -> None: + self._providers: dict[str, Any] = {} + self._builtin_names: set[str] = set() + + def register(self, module: Any, builtin: bool = False) -> None: + self._providers[module.name] = module + if builtin: + self._builtin_names.add(module.name) + + def get(self, name: str) -> Any | None: + return self._providers.get(name) + + def all_names(self) -> list[str]: + return list(self._providers) + + def get_globals(self) -> list[Any]: + return [p for p in self._providers.values() if getattr(p, "global_provider", False)] + + def get_named(self, names: list[str]) -> list[Any]: + return [self._providers[n] for n in names if n in self._providers] + + def load_from_dir(self, path: str, builtin: bool = False) -> CPLoadResult: + result = CPLoadResult() + dir_path = Path(path) + if not dir_path.is_dir(): + return result + prefix = _BUILTIN_PREFIX if builtin else _USER_PREFIX + for py_file in sorted(dir_path.glob("*.py")): + if py_file.stem.startswith("_"): + continue + mod_key = f"{prefix}.{py_file.stem}" + try: + spec = importlib.util.spec_from_file_location(mod_key, py_file) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot create spec for {py_file.name}") + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_key] = mod + spec.loader.exec_module(mod) # type: ignore[union-attr] + missing = [r for r in _REQUIRED if not hasattr(mod, r)] + if missing: + raise AttributeError(f"Missing required definitions: {', '.join(missing)}") + if not callable(getattr(mod, "get_context", None)): + raise AttributeError("get_context must be callable") + self.register(mod, builtin=builtin) + result.loaded.append(mod.name) + log.info("context_provider.loaded", name=mod.name, file=py_file.name) + except Exception as exc: + result.errors[py_file.name] = str(exc) + sys.modules.pop(mod_key, None) + log.warning("context_provider.load_error", file=py_file.name, error=str(exc)) + return result + + def reload_user_providers(self, path: str) -> CPLoadResult: + for name in [n for n in self._providers if n not in self._builtin_names]: + del self._providers[name] + for key in [k for k in sys.modules if k.startswith(f"{_USER_PREFIX}.")]: + del sys.modules[key] + return self.load_from_dir(path, builtin=False) diff --git a/navi/context_providers/public_url.py b/navi/context_providers/public_url.py new file mode 100644 index 0000000..2af3ca1 --- /dev/null +++ b/navi/context_providers/public_url.py @@ -0,0 +1,12 @@ +"""Injects the server's public URL so Navi always knows her own address.""" + +from navi.config import settings + +name = "public_url" +description = "Injects the server's public URL into context on every call." +global_provider = True + + +async def get_context() -> str | None: + url = settings.public_url.rstrip("/") + return f"[System] My public URL: {url}" diff --git a/navi/core/__init__.py b/navi/core/__init__.py index 7d7deee..a675862 100644 --- a/navi/core/__init__.py +++ b/navi/core/__init__.py @@ -1,6 +1,7 @@ from .agent import Agent from .events import AgentEvent, ContextCompressed, StreamEnd, TextDelta, ThinkingDelta, ThinkingEnd, ToolEvent from .registry import BackendRegistry, ProfileRegistry, ToolRegistry, build_default_registries +from navi.context_providers._loader import ContextProviderRegistry from .session import InMemorySessionStore, Session, SessionStore from .sqlite_session_store import SqliteSessionStore from .pg_session_store import PgSessionStore @@ -17,6 +18,7 @@ "BackendRegistry", "ProfileRegistry", "ToolRegistry", + "ContextProviderRegistry", "build_default_registries", "Session", "SessionStore", diff --git a/navi/core/agent.py b/navi/core/agent.py index 2503493..05b1160 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -51,6 +51,7 @@ from .session import SessionStore if TYPE_CHECKING: + from navi.context_providers._loader import ContextProviderRegistry from navi.memory.store import MemoryStore from navi.workers.base import Worker, WorkerContext @@ -202,6 +203,7 @@ backend_registry: BackendRegistry, workers: list["Worker"] | None = None, memory_store: "MemoryStore | None" = None, + cp_registry: "ContextProviderRegistry | None" = None, ) -> None: self._sessions = session_store self._profiles = profile_registry @@ -209,6 +211,7 @@ self._backends = backend_registry self._workers: list["Worker"] = workers or [] self._memory = memory_store + self._cp_registry = cp_registry # ------------------------------------------------------------------ # Public interface @@ -237,11 +240,13 @@ session.context.append(user_msg) await self._sessions.save(session) + ctx_injections = await self._collect_context_injections(profile) for iteration in range(profile.max_iterations): log.debug("agent.iteration", session_id=session_id, iteration=iteration) response = await llm.complete( self._build_context(session.context, profile, mem, - iteration=iteration, max_iterations=profile.max_iterations), + iteration=iteration, max_iterations=profile.max_iterations, + extra_system=ctx_injections), tools=tool_schemas if tools else None, temperature=profile.temperature, model=profile.model, @@ -612,6 +617,8 @@ _known_failed: frozenset[tuple[int, str]] = frozenset() _replan_msg: str | None = None + ctx_injections = await self._collect_context_injections(profile) + # Tool-calling loop — uses stream_complete() for every turn so thinking # is captured in real-time via ThinkingDelta/ThinkingEnd events. for iteration in range(profile.max_iterations): @@ -628,7 +635,8 @@ context_tokens: int | None = None built_ctx = self._build_context(session.context, profile, mem, - iteration=iteration, max_iterations=profile.max_iterations) + iteration=iteration, max_iterations=profile.max_iterations, + extra_system=ctx_injections) if ( profile.goal_anchoring_enabled @@ -1318,6 +1326,24 @@ return None return Message(role="system", content=f"## What I remember about the user\n\n{summary}") + async def _collect_context_injections(self, profile: "AgentProfile") -> list[Message]: + """Run context providers for this profile and return system messages to inject.""" + if not self._cp_registry: + return [] + providers = self._cp_registry.get_globals() + for p in self._cp_registry.get_named(profile.context_providers): + if p not in providers: + providers.append(p) + msgs: list[Message] = [] + for provider in providers: + try: + text = await provider.get_context() + if text: + msgs.append(Message(role="system", content=text)) + except Exception as exc: + log.warning("context_provider.error", name=getattr(provider, "name", "?"), error=str(exc)) + return msgs + def _build_context( self, session_context: list[Message], @@ -1325,6 +1351,7 @@ mem: "Message | None", iteration: int | None = None, max_iterations: int | None = None, + extra_system: list[Message] | None = None, ) -> list[Message]: """Build the full LLM context for one call. @@ -1342,6 +1369,8 @@ result: list[Message] = [system_msg] if mem: result.append(mem) + if extra_system: + result.extend(extra_system) result.extend(conv) if profile.iteration_budget_enabled and iteration is not None and max_iterations is not None: diff --git a/navi/core/registry.py b/navi/core/registry.py index 9f9e647..9c16d1b 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -33,6 +33,7 @@ from navi.tools.write_tool import WriteToolTool from navi.tools.share_file import ShareFileTool from navi.tools.loader import LoadResult, load_tools_from_dir +from navi.context_providers._loader import ContextProviderRegistry class ToolRegistry: @@ -105,7 +106,7 @@ def build_default_registries( memory_store=None, session_store=None, -) -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry]: +) -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry, ContextProviderRegistry]: """Build and populate registries with all built-in components.""" from navi.core.ai_helper import AIHelper @@ -175,4 +176,11 @@ # Patch backend registry into spawn_tool now that it's available spawn_tool._backend_registry = backends - return tools, profiles, backends + # Context providers + cp_registry = ContextProviderRegistry() + from pathlib import Path as _Path + cp_registry.load_from_dir(str(_Path(__file__).parent.parent / "context_providers"), builtin=True) + cp_registry.load_from_dir(settings.context_providers_dir, builtin=False) + reload_tool._cp_registry = cp_registry + + return tools, profiles, backends, cp_registry diff --git a/navi/profiles/base.py b/navi/profiles/base.py index ddd6640..581dfc5 100644 --- a/navi/profiles/base.py +++ b/navi/profiles/base.py @@ -90,3 +90,7 @@ subagent_tools: list[str] = field(default_factory=list) subagent_planning_enabled: bool = False subagent_system_prompt: str = "" + + # Extra context providers to inject for this profile (by name). + # Global providers (global_provider=True) are always injected regardless of this list. + context_providers: list[str] = field(default_factory=list) diff --git a/navi/profiles/loader.py b/navi/profiles/loader.py index e303d5a..456805a 100644 --- a/navi/profiles/loader.py +++ b/navi/profiles/loader.py @@ -93,6 +93,7 @@ subagent_tools=config.get("subagent_tools", []), subagent_planning_enabled=config.get("subagent_planning_enabled", False), subagent_system_prompt=subagent_system_prompt, + context_providers=config.get("context_providers", []), )) log.debug("profile.loader.loaded", profile_id=config["id"]) diff --git a/navi/tools/reload_tools.py b/navi/tools/reload_tools.py index 773bb51..97ad470 100644 --- a/navi/tools/reload_tools.py +++ b/navi/tools/reload_tools.py @@ -1,8 +1,4 @@ -"""Built-in tool to hot-reload user tools from the tools/ directory. - -The agent calls this after writing or editing a file in tools/. -Errors in individual tool files are reported without crashing anything. -""" +"""Built-in tool to hot-reload user tools and context providers without restarting.""" from navi.config import settings @@ -12,8 +8,9 @@ class ReloadToolsTool(Tool): name = "reload_tools" description = ( - "Hot-reload all tools from the tools/ directory without restarting the server. " - "Call this after writing or editing a tool file. " + "Hot-reload all tools from the tools/ directory and context providers from " + "context_providers/ without restarting the server. " + "Call this after writing or editing a tool or context provider file. " "Returns a report of what was loaded and any errors per file." ) parameters = { @@ -22,25 +19,38 @@ "required": [], } - def __init__(self, registry=None) -> None: # registry injected at startup + def __init__(self, registry=None, cp_registry=None) -> None: self._registry = registry + self._cp_registry = cp_registry async def execute(self, params: dict) -> ToolResult: if self._registry is None: return ToolResult(success=False, output="Tool registry not available.", error="no_registry") - result = self._registry.reload_user_tools(settings.tools_dir) - lines = [] - if result.loaded: - lines.append(f"Loaded ({len(result.loaded)}): {', '.join(t.name for t in result.loaded)}") - else: - lines.append("No tools loaded.") + has_errors = False - if result.errors: - lines.append(f"\nErrors ({len(result.errors)}):") - for filename, error in result.errors.items(): + tool_result = self._registry.reload_user_tools(settings.tools_dir) + if tool_result.loaded: + lines.append(f"Tools ({len(tool_result.loaded)}): {', '.join(t.name for t in tool_result.loaded)}") + else: + lines.append("Tools: none.") + if tool_result.errors: + has_errors = True + lines.append(f"Tool errors ({len(tool_result.errors)}):") + for filename, error in tool_result.errors.items(): lines.append(f" {filename}: {error}") - success = not result.errors - return ToolResult(success=success, output="\n".join(lines)) + if self._cp_registry is not None: + cp_result = self._cp_registry.reload_user_providers(settings.context_providers_dir) + if cp_result.loaded: + lines.append(f"Context providers ({len(cp_result.loaded)}): {', '.join(cp_result.loaded)}") + else: + lines.append("Context providers: none.") + if cp_result.errors: + has_errors = True + lines.append(f"Context provider errors ({len(cp_result.errors)}):") + for filename, error in cp_result.errors.items(): + lines.append(f" {filename}: {error}") + + return ToolResult(success=not has_errors, output="\n".join(lines))