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.
This commit is contained in:
tegwick 2026-06-22 10:36:10 +02:00
parent cd5db14fbf
commit 019f6e7dc7
17 changed files with 800 additions and 19 deletions

View file

@ -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,
)

174
src/cya/config.py Normal file
View file

@ -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

View file

@ -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",
]

View file

@ -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},
)

18
src/cya/llm/factory.py Normal file
View file

@ -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"]

73
src/cya/llm/prompt.py Normal file
View file

@ -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)

View file

@ -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,