generated from coulomb/repo-seed
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:
@@ -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
174
src/cya/config.py
Normal 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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
138
src/cya/llm/connect_adapter.py
Normal file
138
src/cya/llm/connect_adapter.py
Normal 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
18
src/cya/llm/factory.py
Normal 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
73
src/cya/llm/prompt.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user