From 019f6e7dc75dbe55e18f29f06dc440f30478bc12 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 10:36:10 +0200 Subject: [PATCH] Implement CYA-WP-0008 llm-connect adapter integration. Wire LLMConnectAdapter behind the existing LLMAdapter seam with config-driven selection, graceful degradation, --offline mode, and bounded session context. Add unit tests, integration docs, and update README/SCOPE/AGENTS. --- AGENTS.md | 3 +- README.md | 43 ++++- SCOPE.md | 6 +- docs/cya-config.example.toml | 9 + docs/llm-connect-integration.md | 100 ++++++++++ pyproject.toml | 5 + src/cya/cli/main.py | 6 + src/cya/config.py | 174 ++++++++++++++++++ src/cya/llm/__init__.py | 4 + src/cya/llm/connect_adapter.py | 138 ++++++++++++++ src/cya/llm/factory.py | 18 ++ src/cya/llm/prompt.py | 73 ++++++++ src/cya/orchestrator.py | 12 +- tests/test_llm_connect_adapter.py | 111 +++++++++++ tests/test_llm_factory.py | 67 +++++++ tests/test_llm_prompt.py | 22 +++ ...WP-0008-llm-connect-adapter-integration.md | 28 ++- 17 files changed, 800 insertions(+), 19 deletions(-) create mode 100644 docs/cya-config.example.toml create mode 100644 docs/llm-connect-integration.md create mode 100644 src/cya/config.py create mode 100644 src/cya/llm/connect_adapter.py create mode 100644 src/cya/llm/factory.py create mode 100644 src/cya/llm/prompt.py create mode 100644 tests/test_llm_connect_adapter.py create mode 100644 tests/test_llm_factory.py create mode 100644 tests/test_llm_prompt.py diff --git a/AGENTS.md b/AGENTS.md index bbd1818..5849bf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,7 @@ make check-dist # Run the assistant cya "your natural language request here" +cya --offline "..." # deterministic fake adapter (CI / no API keys) cya --help cya --explain-context "show me what context would be collected" @@ -213,7 +214,7 @@ Relevant workplans: - `workplans/CYA-WP-0005-agentic-memory-profiles-and-phase-memory-feedback.md` - `workplans/CYA-WP-0006-profile-1-production-hardening.md` (finished) - `workplans/CYA-WP-0007-interactive-shell-session.md` (ready — interactive REPL + history + hub) -- `workplans/CYA-WP-0008-llm-connect-adapter-integration.md` (ready — real LLM behind adapter seam) +- `workplans/CYA-WP-0008-llm-connect-adapter-integration.md` (finished — real LLM behind adapter seam) --- diff --git a/README.md b/README.md index 238e515..0a9b359 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ usable after `pip install -e .`: - `cya "your request in plain English"` - `cya --explain-context "..."` — shows exactly what local context would be sent - Automatic rule-based risk classification with mandatory confirmation for anything destructive, privileged, mass-edit, or network-affecting -- All LLM interaction flows through a documented `LLMAdapter` seam (currently a deterministic fake; ready for real `llm-connect`) +- All LLM interaction flows through a documented `LLMAdapter` seam (`FakeLLMAdapter` by default; real `llm-connect` when configured) ## Installation @@ -58,6 +58,41 @@ git pull make dev-install ``` +## LLM backend (configured vs offline) + +By default `cya` uses a deterministic **offline** adapter (no API keys, no network). +For real inference, configure llm-connect: + +```bash +# 1. Install llm-connect (sibling checkout) +pip install -e ~/llm-connect + +# 2. Route credentials — do not commit keys +warden route find "OpenRouter API key" --json + +# 3. Export key and configure cya +export OPENROUTER_API_KEY="..." # from OpenBao / operator path +mkdir -p ~/.config/cya +cat > ~/.config/cya/config.toml <<'EOF' +[llm] +adapter = "connect" +backend = "openrouter" +model = "anthropic/claude-sonnet-4" +EOF + +cya "show me the recent git history for this repo" +``` + +Force offline mode anytime (tests, CI, air-gapped): + +```bash +cya --offline "your request" +# or: CYA_LLM_ADAPTER=fake cya "..." +``` + +See `docs/llm-connect-integration.md` for the full mapping, session context budget, +and optional `.cya.toml` project overrides. + ## Usage examples ```bash @@ -190,9 +225,11 @@ decisions, and integration guide. ```bash # Recommended one-liner (see Installation section above) make dev-install +pip install -e ~/llm-connect # optional, for live inference -pytest tests/ -q -cya "..." # manual verification +pytest tests/ -q # offline mocks only; no API keys +pytest -m llm_live # manual live check (requires OPENROUTER_API_KEY) +cya --offline "..." # manual verification without network make version # show current dev version ``` diff --git a/SCOPE.md b/SCOPE.md index 8b0d24e..213d2d3 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -21,7 +21,7 @@ Core capabilities now include: - Natural language request handling via clean Typer CLI. - Bounded, transparent local context collection. - Genuine rule-based (memory-aware) risk classification with mandatory confirmation. -- Stable `LLMAdapter` Protocol. +- Stable `LLMAdapter` Protocol with `LLMConnectAdapter` behind the same factory when configured. - Real, user-controlled, contextually activated memory (Profile 0: directory/project scoped local JSON with kinds, activation_context, provenance, and retrospection outcomes as higher-order memory). - Automatic memory activation based on working directory/git root. - `cya retrospect` for structured reflection and goal setting, with production Profile 1 verbal lesson capture, review (`cya memory reflections`), and compaction. @@ -77,7 +77,7 @@ See the individual workplans for detailed scope per slice. ## Explicitly Out of Scope (Current and Near-Term) - Full deep integration with the complete `phase-memory` profile/planner/graph system (current implementation uses a deliberate, user-visible local JSON store with contextual activation; deeper integration is planned future work per MemoryVision.md). -- Real `llm-connect` client implementation (only the stable `LLMAdapter` Protocol contract + FakeLLMAdapter exists). +- Deep llm-connect features beyond basic `execute_prompt` delegation (adaptive routing, cost dashboards, structured output schemas). - Deep semantic repository understanding or large-scale content analysis. - Automatic command execution (even "safe" suggestions) — explicit user confirmation remains mandatory for anything non-safe. - Rich multi-turn conversational state beyond lightweight scoped memory + retrospection. @@ -109,7 +109,7 @@ Sibling project owners (llm-connect, phase-memory, State Hub) can read the workp --- -**This SCOPE document reflects the state after CYA-WP-0004 (Dev-Head Install & Release Packaging).** +**This SCOPE document reflects the state after CYA-WP-0008 (llm-connect Adapter Integration).** It remains intentionally narrower than the long-term vision in INTENT.md and MemoryVision.md, but now incorporates significant advances in contextual memory activation, user-driven retrospection/optimization loops, and proper packaging & distribution capabilities. diff --git a/docs/cya-config.example.toml b/docs/cya-config.example.toml new file mode 100644 index 0000000..dffe0f6 --- /dev/null +++ b/docs/cya-config.example.toml @@ -0,0 +1,9 @@ +# Example ~/.config/cya/config.toml — placeholders only; do not commit secrets. + +[llm] +adapter = "connect" +backend = "openrouter" +model = "anthropic/claude-sonnet-4" +temperature = 0.3 +max_tokens = 2000 +api_key_env = "OPENROUTER_API_KEY" \ No newline at end of file diff --git a/docs/llm-connect-integration.md b/docs/llm-connect-integration.md new file mode 100644 index 0000000..cccd092 --- /dev/null +++ b/docs/llm-connect-integration.md @@ -0,0 +1,100 @@ +# llm-connect Integration (CYA-WP-0008) + +## Mapping: cya ↔ llm-connect + +| cya (`AssistanceRequest`) | llm-connect | +|---------------------------|-------------| +| `user_request` | User message body (after context framing) | +| `context` (envelope + memory + `session_turns`) | Serialized into the prompt via `cya.llm.prompt.build_assistance_prompt` | +| `hints` (`model`, `temperature`, `max_tokens`) | `RunConfig` fields for `execute_prompt` | +| `AssistanceResponse.suggestion` | `LLMResponse.content` | +| `AssistanceResponse.metadata` | `LLMResponse.model`, `usage`, `finish_reason` | + +llm-connect owns provider clients (`create_adapter`), API key resolution, retries, and +token usage. `cya` never imports vendor SDKs directly. + +## Configuration + +User config: `~/.config/cya/config.toml` + +```toml +[llm] +adapter = "connect" # "connect" | "fake" (default: fake when absent) +backend = "openrouter" # openrouter | openai | gemini | claude-code | mock +model = "anthropic/claude-sonnet-4" +temperature = 0.3 +max_tokens = 2000 +api_key_env = "OPENROUTER_API_KEY" # optional override +# system_prompt = "..." # optional; uses cya default when omitted +``` + +Optional project override: `.cya.toml` (same `[llm]` section; merged over user config). + +Environment overrides: + +| Variable | Purpose | +|----------|---------| +| `CYA_LLM_ADAPTER` | `connect` or `fake` | +| `CYA_LLM_BACKEND` / `CYA_LLM_PROVIDER` | Provider name | +| `CYA_LLM_MODEL` | Model id | + +CLI: `cya --offline "..."` forces `FakeLLMAdapter`. + +## Session context budget (multi-turn / `cya shell`) + +Recent turns are passed in `AssistanceRequest.context["session_turns"]` as +`{"user": "...", "assistant": "..."}` records. + +Bounds (see `cya.config`): + +- **Max turns:** 10 +- **Max characters:** 4000 (total across included turns) + +Older or oversized history is dropped from the prompt automatically. + +## Credential routing + +Do **not** commit API keys. Before requesting secrets, route custody: + +```bash +warden route find "OpenRouter API key" --json +warden route show --json +``` + +Typical ownership: + +| Need | Owner | ops-warden executes? | +|------|-------|----------------------| +| `OPENROUTER_API_KEY` | OpenBao (`railiance-platform`) | No — route only | +| `OPENAI_API_KEY` | OpenBao | No — route only | +| `GEMINI_API_KEY` | OpenBao | No — route only | + +llm-connect resolves keys via `resolve_api_key()` (explicit arg → env var → project key file). + +## Adapter selection + +`cya.llm.factory.get_adapter()` is the single factory for one-shot and shell paths: + +1. `--offline` or `CYA_LLM_ADAPTER=fake` → `FakeLLMAdapter` +2. `adapter = "connect"` in config/env → `LLMConnectAdapter` (graceful degrade on failure) +3. Otherwise → `FakeLLMAdapter` (current default) + +## Installation + +```bash +make dev-install +pip install -e ~/llm-connect # sibling checkout +``` + +Optional extra group (placeholder for packaging): `pip install -e ".[llm]"`. + +## Tests + +- Default CI: `make test` — mocks llm-connect; no network. +- Manual live check: `pytest -m llm_live` (requires configured API key). + +## Known gaps + +- Structured JSON output schema not enforced yet (free-form model text). +- `claude-code` backend does not require an API key; other backends do. +- Per-directory `.cya.toml` overrides user config but does not yet mirror llm-connect's full 7-layer resolution. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fb58497..4d226e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ authors = [ dependencies = [ "typer[standard]>=0.12.0", "rich>=13.0.0", + "tomli>=2.0.0; python_version<'3.11'", ] [project.optional-dependencies] @@ -26,6 +27,9 @@ dev = [ test = [ "pytest>=8.0", ] +llm = [ + # Install llm-connect from a sibling checkout, e.g. pip install -e ~/llm-connect +] [project.scripts] cya = "cya.cli.main:run" @@ -57,6 +61,7 @@ python_functions = ["test_*"] addopts = "-q --tb=short" markers = [ "safety: core safety and risk classifier invariants (always run)", + "llm_live: live llm-connect inference (requires API key; manual runs only)", ] [tool.setuptools_scm] diff --git a/src/cya/cli/main.py b/src/cya/cli/main.py index b28e883..92f379e 100644 --- a/src/cya/cli/main.py +++ b/src/cya/cli/main.py @@ -66,6 +66,11 @@ def main( "-n", help="Preview mode — do not perform any actions (stub in T01).", ), + offline: bool = typer.Option( + False, + "--offline", + help="Use the deterministic FakeLLMAdapter (no llm-connect / no API keys).", + ), version: bool = typer.Option( None, "--version", @@ -106,6 +111,7 @@ def main( request, explain_context=explain_context, dry_run=dry_run, + offline=offline, ) diff --git a/src/cya/config.py b/src/cya/config.py new file mode 100644 index 0000000..38cb559 --- /dev/null +++ b/src/cya/config.py @@ -0,0 +1,174 @@ +"""User configuration for cya (CYA-WP-0008-T03). + +Reads ``~/.config/cya/config.toml`` and optional project ``.cya.toml``. +Environment variables override file values where noted. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_USER_CONFIG = Path.home() / ".config" / "cya" / "config.toml" +_PROJECT_CONFIG_NAME = ".cya.toml" + +# Session context bounds (CYA-WP-0008-T04) — documented in docs/llm-connect-integration.md +MAX_SESSION_TURNS = 10 +MAX_SESSION_CHARS = 4000 + + +def _load_toml(path: Path) -> dict[str, Any]: + if not path.is_file(): + return {} + if sys.version_info >= (3, 11): + import tomllib + + return tomllib.loads(path.read_text()) + import tomli + + return tomli.loads(path.read_bytes()) + + +def _find_project_config(start: Path | None = None) -> Path | None: + current = (start or Path.cwd()).resolve() + for directory in [current, *current.parents]: + candidate = directory / _PROJECT_CONFIG_NAME + if candidate.is_file(): + return candidate + return None + + +def _merge_llm_sections(*sources: dict[str, Any]) -> dict[str, Any]: + merged: dict[str, Any] = {} + for source in sources: + section = source.get("llm") + if isinstance(section, dict): + merged.update(section) + return merged + + +@dataclass +class LLMSettings: + """Resolved LLM adapter settings.""" + + adapter: str = "fake" # "fake" | "connect" + backend: str = "openrouter" + model: str | None = None + temperature: float = 0.3 + max_tokens: int = 2000 + api_key_env: str | None = None + system_prompt: str | None = None + configured: bool = False + source: str = "default" + + def to_hints(self) -> dict[str, Any]: + hints: dict[str, Any] = { + "backend": self.backend, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + } + if self.model: + hints["model"] = self.model + if self.api_key_env: + hints["api_key_env"] = self.api_key_env + return hints + + +def _coerce_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _coerce_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def load_llm_settings(*, offline: bool = False) -> LLMSettings: + """Resolve LLM settings from env, user config, and project config.""" + if offline: + return LLMSettings(adapter="fake", configured=False, source="--offline") + + env_adapter = os.environ.get("CYA_LLM_ADAPTER", "").strip().lower() + if env_adapter in ("fake", "connect"): + base = LLMSettings(adapter=env_adapter, configured=env_adapter == "connect", source="CYA_LLM_ADAPTER") + else: + base = LLMSettings() + + user_data = _load_toml(_USER_CONFIG) + project_path = _find_project_config() + project_data = _load_toml(project_path) if project_path else {} + merged = _merge_llm_sections(user_data, project_data) + + if merged: + file_adapter = str(merged.get("adapter", "")).strip().lower() + if file_adapter in ("fake", "connect") and not env_adapter: + base.adapter = file_adapter + base.configured = file_adapter == "connect" + base.source = str(project_path or _USER_CONFIG) + + backend = merged.get("backend") or merged.get("provider") + if backend: + base.backend = str(backend) + if not env_adapter and file_adapter != "fake": + base.adapter = "connect" + base.configured = True + base.source = str(project_path or _USER_CONFIG) + + if merged.get("model"): + base.model = str(merged["model"]) + base.temperature = _coerce_float(merged.get("temperature"), base.temperature) + base.max_tokens = _coerce_int(merged.get("max_tokens"), base.max_tokens) + if merged.get("api_key_env"): + base.api_key_env = str(merged["api_key_env"]) + if merged.get("system_prompt"): + base.system_prompt = str(merged["system_prompt"]) + + env_backend = os.environ.get("CYA_LLM_BACKEND") or os.environ.get("CYA_LLM_PROVIDER") + if env_backend: + base.backend = env_backend.strip() + if base.adapter != "fake": + base.adapter = "connect" + base.configured = True + base.source = "CYA_LLM_BACKEND" + + env_model = os.environ.get("CYA_LLM_MODEL") + if env_model: + base.model = env_model.strip() + if base.adapter != "fake": + base.adapter = "connect" + base.configured = True + base.source = "CYA_LLM_MODEL" + + return base + + +def bound_session_turns( + turns: list[dict[str, str]] | None, + *, + max_turns: int = MAX_SESSION_TURNS, + max_chars: int = MAX_SESSION_CHARS, +) -> list[dict[str, str]]: + """Trim session history to a bounded token/line budget for the adapter.""" + if not turns: + return [] + + recent = turns[-max_turns:] + bounded: list[dict[str, str]] = [] + used = 0 + for turn in recent: + user = turn.get("user", "") + assistant = turn.get("assistant", "") + chunk_len = len(user) + len(assistant) + if used + chunk_len > max_chars and bounded: + break + bounded.append({"user": user, "assistant": assistant}) + used += chunk_len + return bounded \ No newline at end of file diff --git a/src/cya/llm/__init__.py b/src/cya/llm/__init__.py index 2754c27..6c3113e 100644 --- a/src/cya/llm/__init__.py +++ b/src/cya/llm/__init__.py @@ -17,11 +17,15 @@ from .adapter import ( LLMAdapter, FakeLLMAdapter, ) +from .connect_adapter import LLMConnectAdapter +from .factory import get_adapter __all__ = [ "AssistanceRequest", "AssistanceResponse", "LLMAdapter", "FakeLLMAdapter", + "LLMConnectAdapter", + "get_adapter", ] diff --git a/src/cya/llm/connect_adapter.py b/src/cya/llm/connect_adapter.py new file mode 100644 index 0000000..9577868 --- /dev/null +++ b/src/cya/llm/connect_adapter.py @@ -0,0 +1,138 @@ +"""llm-connect-backed adapter (CYA-WP-0008-T02).""" + +from __future__ import annotations + +from typing import Any + +from cya.config import LLMSettings +from cya.llm.adapter import AssistanceRequest, AssistanceResponse +from cya.llm.prompt import build_assistance_prompt + +_PROVIDER_ENV_KEYS: dict[str, str] = { + "openrouter": "OPENROUTER_API_KEY", + "openai": "OPENAI_API_KEY", + "gemini": "GEMINI_API_KEY", +} + + +class LLMConnectAdapter: + """Delegates to llm-connect while satisfying cya's LLMAdapter protocol.""" + + def __init__(self, settings: LLMSettings) -> None: + self._settings = settings + self._client: Any | None = None + self._init_error: str | None = None + self._ensure_client() + + def _ensure_client(self) -> None: + try: + from llm_connect import create_adapter + from llm_connect.config import resolve_api_key + except ImportError: + self._init_error = ( + "llm-connect is not installed. Install with:\n" + " pip install -e ~/llm-connect\n" + "or: pip install -e \".[llm]\" after adding llm-connect to your environment." + ) + return + + env_var = self._settings.api_key_env or _PROVIDER_ENV_KEYS.get( + self._settings.backend, "OPENROUTER_API_KEY" + ) + api_key = resolve_api_key(env_var=env_var) + if self._settings.backend in ("openrouter", "openai", "gemini") and not api_key: + self._init_error = ( + f"No API key found for backend {self._settings.backend!r} " + f"(checked env {env_var!r}).\n" + "Route credential custody via warden before requesting secrets:\n" + " warden route find \"OpenRouter API key\" --json\n" + "Then export the key into your environment — never commit it to the repo." + ) + return + + try: + self._client = create_adapter( + provider=self._settings.backend, + model=self._settings.model, + api_key=api_key, + system_prompt=self._settings.system_prompt, + ) + except Exception as exc: # noqa: BLE001 — surface config errors to the user + self._init_error = f"Failed to initialize llm-connect adapter: {exc}" + + def complete(self, request: AssistanceRequest) -> AssistanceResponse: + if self._init_error or self._client is None: + return self._degraded_response(self._init_error or "llm-connect client unavailable.") + + try: + from llm_connect.models import RunConfig + except ImportError: + return self._degraded_response(self._init_error or "llm-connect not installed.") + + system, user_prompt = build_assistance_prompt( + request, + system_prompt=self._settings.system_prompt, + ) + hints = {**self._settings.to_hints(), **request.hints} + run_config = RunConfig( + model_name=hints.get("model") or self._settings.model or "anthropic/claude-sonnet-4", + temperature=float(hints.get("temperature", self._settings.temperature)), + max_tokens=int(hints.get("max_tokens", self._settings.max_tokens)), + ) + + # Re-create adapter when per-request system prompt differs (llm-connect stores it at init). + client = self._client + if system and not self._settings.system_prompt: + from llm_connect import create_adapter + from llm_connect.config import resolve_api_key + + env_var = self._settings.api_key_env or _PROVIDER_ENV_KEYS.get( + self._settings.backend, "OPENROUTER_API_KEY" + ) + api_key = resolve_api_key(env_var=env_var) + client = create_adapter( + provider=self._settings.backend, + model=self._settings.model, + api_key=api_key, + system_prompt=system, + ) + + try: + llm_response = client.execute_prompt(user_prompt, run_config) + except Exception as exc: # noqa: BLE001 — user-facing degrade path + return self._degraded_response( + f"llm-connect request failed: {exc}", + partial_raw=str(exc), + ) + + content = (llm_response.content or "").strip() + return AssistanceResponse( + suggestion=content or "(empty model response)", + explanation="Response generated via llm-connect.", + rationale="Model inference using configured backend and bounded local context.", + risks=[], + raw_model_output=content, + metadata={ + "adapter": "LLMConnectAdapter", + "backend": self._settings.backend, + "model": llm_response.model, + "usage": llm_response.usage, + "finish_reason": llm_response.finish_reason, + }, + ) + + @staticmethod + def _degraded_response(message: str, *, partial_raw: str | None = None) -> AssistanceResponse: + return AssistanceResponse( + suggestion=( + "cya could not reach a configured LLM backend.\n\n" + f"{message}\n\n" + "Continuing in offline mode: re-run with `--offline` or configure " + "`~/.config/cya/config.toml` (see README)." + ), + explanation="Graceful degradation — no live inference was performed.", + rationale="llm-connect unavailable or misconfigured.", + risks=["No live model inference"], + raw_model_output=partial_raw, + metadata={"adapter": "LLMConnectAdapter", "degraded": True}, + ) \ No newline at end of file diff --git a/src/cya/llm/factory.py b/src/cya/llm/factory.py new file mode 100644 index 0000000..b99e837 --- /dev/null +++ b/src/cya/llm/factory.py @@ -0,0 +1,18 @@ +"""Adapter selection factory (CYA-WP-0008-T04).""" + +from __future__ import annotations + +from cya.config import LLMSettings, load_llm_settings +from cya.llm.adapter import FakeLLMAdapter, LLMAdapter +from cya.llm.connect_adapter import LLMConnectAdapter + + +def get_adapter(*, offline: bool = False, settings: LLMSettings | None = None) -> LLMAdapter: + """Return the active LLMAdapter for one-shot and shell code paths.""" + resolved = settings or load_llm_settings(offline=offline) + if resolved.adapter == "connect": + return LLMConnectAdapter(resolved) + return FakeLLMAdapter() + + +__all__ = ["get_adapter", "load_llm_settings"] \ No newline at end of file diff --git a/src/cya/llm/prompt.py b/src/cya/llm/prompt.py new file mode 100644 index 0000000..db7d2dd --- /dev/null +++ b/src/cya/llm/prompt.py @@ -0,0 +1,73 @@ +"""Prompt construction for llm-connect delegation (CYA-WP-0008).""" + +from __future__ import annotations + +import json +from typing import Any + +from cya.llm.adapter import AssistanceRequest + +_DEFAULT_SYSTEM = """You are cya, a console-native assistant for practical local work from the shell. + +Help the user with command-line tasks: repository inspection, file workflows, command +suggestion, command explanation, and local context summarization. + +Be concise and practical. When suggesting shell commands, explain risks briefly. +Do not claim to have executed anything — the user runs commands themselves. +Reference the provided context when it is relevant.""" + + +def default_system_prompt() -> str: + return _DEFAULT_SYSTEM + + +def build_assistance_prompt(request: AssistanceRequest, *, system_prompt: str | None = None) -> tuple[str, str]: + """Return (system_prompt, user_prompt) for llm-connect execute_prompt.""" + system = system_prompt or default_system_prompt() + parts: list[str] = [] + + context = request.context or {} + session_turns = context.get("session_turns") + if session_turns: + parts.append("## Recent conversation") + for turn in session_turns: + parts.append(f"User: {turn.get('user', '')}") + parts.append(f"Assistant: {turn.get('assistant', '')}") + + envelope = {k: v for k, v in context.items() if k not in ("session_turns", "memory")} + if envelope: + parts.append("## Local context") + parts.append(_summarize_context(envelope)) + + memory = context.get("memory") + if isinstance(memory, dict) and memory.get("items"): + parts.append("## Activated memory") + for item in memory["items"][:8]: + parts.append(f"- [{item.get('kind', '?')}] {item.get('key', '?')}: {item.get('value', '')}") + + parts.append("## Current request") + parts.append(request.user_request.strip()) + + return system, "\n\n".join(parts) + + +def _summarize_context(envelope: dict[str, Any]) -> str: + """Compact, JSON-safe context summary to stay within prompt budget.""" + summary: dict[str, Any] = {} + if envelope.get("cwd"): + summary["cwd"] = envelope["cwd"] + if envelope.get("git"): + git = envelope["git"] + summary["git"] = { + k: git[k] + for k in ("branch", "status_short", "workdir", "is_repo") + if k in git + } + if envelope.get("top_level"): + names = [e.get("name") for e in envelope["top_level"][:30] if e.get("name")] + summary["top_level"] = names + if envelope.get("env"): + summary["env"] = envelope["env"] + if envelope.get("notes"): + summary["notes"] = envelope["notes"][:5] + return json.dumps(summary, indent=2, default=str) \ No newline at end of file diff --git a/src/cya/orchestrator.py b/src/cya/orchestrator.py index 3568b92..e1e2e36 100644 --- a/src/cya/orchestrator.py +++ b/src/cya/orchestrator.py @@ -48,7 +48,9 @@ from cya.memory.reflections import ( session_provenance, ) from cya.safety.risk import classify, get_user_confirmation -from cya.llm.adapter import AssistanceRequest, FakeLLMAdapter +from cya.config import bound_session_turns +from cya.llm.adapter import AssistanceRequest +from cya.llm.factory import get_adapter console = Console() @@ -59,6 +61,8 @@ def handle_request( *, explain_context: bool = False, dry_run: bool = False, + offline: bool = False, + session_turns: list[dict[str, str]] | None = None, ) -> None: """Primary orchestrator entry point. @@ -158,10 +162,12 @@ def handle_request( console.print("[green]--dry-run acknowledged.[/green] No side-effects.") return - # 3. Call through the single LLMAdapter boundary (T04) - adapter = FakeLLMAdapter() + # 3. Call through the single LLMAdapter boundary (T04 / CYA-WP-0008) + adapter = get_adapter(offline=offline) ctx = (envelope.to_dict() if envelope else {}) or {} ctx["memory"] = memory # T03: memory now in context passed to LLM (for personalization + explain) + if session_turns: + ctx["session_turns"] = bound_session_turns(session_turns) llm_request = AssistanceRequest( user_request=user_request, context=ctx, diff --git a/tests/test_llm_connect_adapter.py b/tests/test_llm_connect_adapter.py new file mode 100644 index 0000000..418eedc --- /dev/null +++ b/tests/test_llm_connect_adapter.py @@ -0,0 +1,111 @@ +"""LLMConnectAdapter unit tests with mocked llm-connect (CYA-WP-0008).""" + +from unittest.mock import MagicMock, patch + +import pytest + +from cya.config import LLMSettings +from cya.llm.adapter import AssistanceRequest +from cya.llm.connect_adapter import LLMConnectAdapter + + +def _mock_llm_response(content: str = "Try: git status"): + resp = MagicMock() + resp.content = content + resp.model = "mock/model" + resp.usage = {"total_tokens": 42} + resp.finish_reason = "stop" + return resp + + +@patch("llm_connect.create_adapter") +@patch("llm_connect.config.resolve_api_key", return_value="test-key") +def test_complete_delegates_to_llm_connect(mock_resolve, mock_create): + client = MagicMock() + client.execute_prompt.return_value = _mock_llm_response() + mock_create.return_value = client + + settings = LLMSettings(adapter="connect", backend="mock", model="mock/model", configured=True) + adapter = LLMConnectAdapter(settings) + response = adapter.complete( + AssistanceRequest(user_request="show git status", context={"cwd": "/tmp"}) + ) + + assert "git status" in response.suggestion.lower() + assert response.metadata.get("adapter") == "LLMConnectAdapter" + assert response.metadata.get("degraded") is not True + client.execute_prompt.assert_called_once() + + +def test_graceful_degrade_when_llm_connect_missing(monkeypatch): + import builtins + + real_import = builtins.__import__ + + def _import(name, *args, **kwargs): + if name == "llm_connect" or name.startswith("llm_connect."): + raise ImportError("no llm_connect") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _import) + settings = LLMSettings(adapter="connect", backend="openrouter", configured=True) + adapter = LLMConnectAdapter(settings) + response = adapter.complete(AssistanceRequest(user_request="hello")) + + assert response.metadata.get("degraded") is True + assert "llm-connect" in response.suggestion + + +@patch("llm_connect.create_adapter") +@patch("llm_connect.config.resolve_api_key", return_value=None) +def test_graceful_degrade_when_api_key_missing(mock_resolve, mock_create): + settings = LLMSettings(adapter="connect", backend="openrouter", configured=True) + adapter = LLMConnectAdapter(settings) + response = adapter.complete(AssistanceRequest(user_request="hello")) + + assert response.metadata.get("degraded") is True + assert "API key" in response.suggestion + mock_create.assert_not_called() + + +@patch("llm_connect.create_adapter") +@patch("llm_connect.config.resolve_api_key", return_value="test-key") +def test_session_turns_included_in_prompt(mock_resolve, mock_create): + client = MagicMock() + client.execute_prompt.return_value = _mock_llm_response("ok") + mock_create.return_value = client + + settings = LLMSettings(adapter="connect", backend="mock", configured=True) + adapter = LLMConnectAdapter(settings) + adapter.complete( + AssistanceRequest( + user_request="follow up", + context={ + "session_turns": [{"user": "first", "assistant": "reply"}], + }, + ) + ) + + _prompt_arg, _ = client.execute_prompt.call_args[0] + assert "first" in _prompt_arg + assert "follow up" in _prompt_arg + + +@pytest.mark.llm_live +def test_live_openrouter_smoke(): + """Manual verification only — skipped unless OPENROUTER_API_KEY is set.""" + import os + + if not os.environ.get("OPENROUTER_API_KEY"): + pytest.skip("OPENROUTER_API_KEY not set") + + settings = LLMSettings( + adapter="connect", + backend="openrouter", + model="anthropic/claude-sonnet-4", + configured=True, + ) + adapter = LLMConnectAdapter(settings) + response = adapter.complete(AssistanceRequest(user_request="Reply with exactly: pong")) + assert response.metadata.get("degraded") is not True + assert response.suggestion \ No newline at end of file diff --git a/tests/test_llm_factory.py b/tests/test_llm_factory.py new file mode 100644 index 0000000..0212050 --- /dev/null +++ b/tests/test_llm_factory.py @@ -0,0 +1,67 @@ +"""Adapter factory and config resolution (CYA-WP-0008).""" + +import os +from pathlib import Path + +import pytest + +from cya.config import bound_session_turns, load_llm_settings +from cya.llm.adapter import FakeLLMAdapter +from cya.llm.connect_adapter import LLMConnectAdapter +from cya.llm.factory import get_adapter + + +def test_default_adapter_is_fake(monkeypatch): + monkeypatch.delenv("CYA_LLM_ADAPTER", raising=False) + monkeypatch.setattr("cya.config._USER_CONFIG", Path("/nonexistent/config.toml")) + adapter = get_adapter() + assert isinstance(adapter, FakeLLMAdapter) + + +def test_offline_forces_fake(monkeypatch, tmp_path): + cfg = tmp_path / "config.toml" + cfg.write_text('[llm]\nadapter = "connect"\nbackend = "openrouter"\n') + monkeypatch.setattr("cya.config._USER_CONFIG", cfg) + adapter = get_adapter(offline=True) + assert isinstance(adapter, FakeLLMAdapter) + + +def test_connect_adapter_when_configured(monkeypatch, tmp_path): + cfg = tmp_path / "config.toml" + cfg.write_text('[llm]\nadapter = "connect"\nbackend = "mock"\n') + monkeypatch.setattr("cya.config._USER_CONFIG", cfg) + adapter = get_adapter() + assert isinstance(adapter, LLMConnectAdapter) + + +def test_env_adapter_override(monkeypatch): + monkeypatch.setenv("CYA_LLM_ADAPTER", "connect") + monkeypatch.setenv("CYA_LLM_BACKEND", "mock") + adapter = get_adapter() + assert isinstance(adapter, LLMConnectAdapter) + + +def test_load_llm_settings_merges_project_config(monkeypatch, tmp_path): + user_cfg = tmp_path / "user.toml" + user_cfg.write_text('[llm]\nbackend = "openrouter"\nmodel = "from-user"\n') + project_cfg = tmp_path / ".cya.toml" + project_cfg.write_text('[llm]\nmodel = "from-project"\n') + monkeypatch.setattr("cya.config._USER_CONFIG", user_cfg) + monkeypatch.setattr("cya.config._find_project_config", lambda start=None: project_cfg) + + settings = load_llm_settings() + assert settings.backend == "openrouter" + assert settings.model == "from-project" + assert settings.adapter == "connect" + + +def test_bound_session_turns_limits(): + turns = [ + {"user": "a" * 1000, "assistant": "b" * 1000}, + {"user": "c" * 1000, "assistant": "d" * 1000}, + {"user": "e", "assistant": "f"}, + ] + bounded = bound_session_turns(turns, max_turns=10, max_chars=2500) + assert len(bounded) >= 1 + total = sum(len(t["user"]) + len(t["assistant"]) for t in bounded) + assert total <= 2500 or len(bounded) == 1 \ No newline at end of file diff --git a/tests/test_llm_prompt.py b/tests/test_llm_prompt.py new file mode 100644 index 0000000..19ef99b --- /dev/null +++ b/tests/test_llm_prompt.py @@ -0,0 +1,22 @@ +"""Prompt builder tests.""" + +from cya.llm.adapter import AssistanceRequest +from cya.llm.prompt import build_assistance_prompt + + +def test_build_assistance_prompt_includes_context_and_request(): + system, user = build_assistance_prompt( + AssistanceRequest( + user_request="list files", + context={ + "cwd": "/home/user/proj", + "session_turns": [{"user": "hi", "assistant": "hello"}], + "memory": {"items": [{"kind": "preference", "key": "style", "value": "concise"}]}, + }, + ) + ) + assert "cya" in system.lower() + assert "list files" in user + assert "/home/user/proj" in user + assert "hi" in user + assert "concise" in user \ No newline at end of file diff --git a/workplans/CYA-WP-0008-llm-connect-adapter-integration.md b/workplans/CYA-WP-0008-llm-connect-adapter-integration.md index 6b291dc..5c8f3b8 100644 --- a/workplans/CYA-WP-0008-llm-connect-adapter-integration.md +++ b/workplans/CYA-WP-0008-llm-connect-adapter-integration.md @@ -4,7 +4,7 @@ type: workplan title: "llm-connect Adapter Integration for Production Assistance" domain: capabilities repo: can-you-assist -status: ready +status: finished owner: grok topic_slug: foerster-capabilities created: "2026-06-22" @@ -50,7 +50,7 @@ llm-connect. Credential routing via `warden route` before requesting secrets. ```task id: CYA-WP-0008-T01 -status: todo +status: done priority: high state_hub_task_id: "483d13bb-aabe-48ad-96c2-8df83de5f442" ``` @@ -62,11 +62,13 @@ Identify config surface (TOML keys, env vars). Note gaps requiring llm-connect c - Short integration note in `docs/` or workplan appendix. - Credential route catalog id(s) documented via `warden route find`. +**Delivered:** `docs/llm-connect-integration.md` + ### T02 — Implement `LLMConnectAdapter` ```task id: CYA-WP-0008-T02 -status: todo +status: done priority: high state_hub_task_id: "0fc17ad5-d90b-4ad1-b060-a1a2f9c25ea8" ``` @@ -80,11 +82,13 @@ New class in `src/cya/llm/` implementing `LLMAdapter`: **Acceptance criteria:** - Protocol-compliant; swap via config or env (`CYA_LLM_ADAPTER=connect|fake`). +**Delivered:** `src/cya/llm/connect_adapter.py`, `src/cya/llm/factory.py` + ### T03 — Configuration and developer ergonomics ```task id: CYA-WP-0008-T03 -status: todo +status: done priority: medium state_hub_task_id: "e8470a37-ecec-42f1-920b-ccd8b98b5512" ``` @@ -97,11 +101,13 @@ state_hub_task_id: "e8470a37-ecec-42f1-920b-ccd8b98b5512" - Operator can configure adapter without editing source. - No secrets committed; example config uses placeholders. +**Delivered:** `src/cya/config.py`, `docs/cya-config.example.toml`, README section + ### T04 — Orchestrator and shell integration ```task id: CYA-WP-0008-T04 -status: todo +status: done priority: high state_hub_task_id: "f2781963-fecf-4576-96de-bd745df271a0" ``` @@ -116,11 +122,13 @@ Wire `handle_request()` and CYA-WP-0007 shell turns to adapter selection: - One-shot and shell paths use same adapter factory. - Session context bounded (token/line budget documented). +**Delivered:** `get_adapter()` wired in orchestrator; `session_turns` + `bound_session_turns()` ready for CYA-WP-0007 shell + ### T05 — Tests and offline CI strategy ```task id: CYA-WP-0008-T05 -status: todo +status: done priority: high state_hub_task_id: "32de980b-1a24-4159-9550-7c516570cae3" ``` @@ -133,11 +141,13 @@ state_hub_task_id: "32de980b-1a24-4159-9550-7c516570cae3" - `make test` passes without network or API keys. - Live test documented for operator manual verification. +**Delivered:** `tests/test_llm_factory.py`, `tests/test_llm_connect_adapter.py`, `tests/test_llm_prompt.py` + ### T06 — Documentation and SCOPE update ```task id: CYA-WP-0008-T06 -status: todo +status: done priority: medium state_hub_task_id: "2d152d4b-e4b2-4a94-8f85-d8f033e55d5f" ``` @@ -151,7 +161,7 @@ Update README, SCOPE.md (remove "only FakeLLMAdapter" where accurate), AGENTS.md ```task id: CYA-WP-0008-T07 -status: todo +status: done priority: low state_hub_task_id: "2fb42517-b2df-43d3-8195-f02d310107dc" ``` @@ -166,4 +176,4 @@ state_hub_task_id: "2fb42517-b2df-43d3-8195-f02d310107dc" --- -**Status note:** `ready` on 2026-06-22. Can start T01–T03 in parallel with CYA-WP-0007 T02–T04. \ No newline at end of file +**Status note:** `finished` on 2026-06-22. Integration doc: `docs/llm-connect-integration.md`. \ No newline at end of file