Files
can-you-assist/src/cya/orchestrator.py
tegwick 019f6e7dc7 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.
2026-06-22 10:36:10 +02:00

392 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Assistance orchestrator.
The piece that turns raw user intent + collected context into a well-formed
request for the LLM adapter, then turns the adapter response into the
final terminal output the user sees.
Key responsibilities:
- Coordinate context collector, memory (Profile 0 via recall with activation_context),
rule-based risk classifier, and LLMAdapter.
- Wire memory influence into every assistance cycle and `--explain-context`.
- Own the `cya retrospect` flow that feeds higher-order memory back into the system.
- Keep the CLI surface thin.
**Memory note (Profile 0):** This module is the primary consumer of the
stable memory ports defined in src/cya/memory/__init__.py. All calls use
activation_context derived from the current working directory + git root.
See MemoryVision.md "Profile 0 Baseline" and CYA-WP-0005 for the evolution
path to Profiles 13.
See workplan CYA-WP-0001 (core) + CYA-WP-0003 (memory wiring + retrospect)
+ CYA-WP-0005 (profile formalization).
"""
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from rich.panel import Panel
from cya.context.collector import collect, render_explanation
from cya.memory import (
recall_preferences,
remember_retrospection_outcome,
KIND_RETROSPECTION,
KIND_INTERACTION_GOAL,
KIND_REFLECTION,
)
from cya.memory.reflections import (
REFLECTION_CAPTURE_PROMPTS,
collect_lessons_from_answers,
compact_reflections,
find_duplicate_reflection_groups,
format_reflection_surfacing,
preview_lessons,
save_reflection_lessons,
session_provenance,
)
from cya.safety.risk import classify, get_user_confirmation
from cya.config import bound_session_turns
from cya.llm.adapter import AssistanceRequest
from cya.llm.factory import get_adapter
console = Console()
def handle_request(
user_request: str,
*,
explain_context: bool = False,
dry_run: bool = False,
offline: bool = False,
session_turns: list[dict[str, str]] | None = None,
) -> None:
"""Primary orchestrator entry point.
This is what the CLI (and future tests / other front-ends) should call.
It coordinates the full current flow:
context → safety (with mandatory confirmation) → LLMAdapter → render
"""
# 1. Context (always cheap; needed for safety "affected" and for the adapter)
try:
envelope = collect(".")
except Exception:
envelope = None
if explain_context and envelope:
try:
explanation = render_explanation(envelope)
console.print(
Panel(
explanation,
title="Context Envelope (T02)",
border_style="green",
padding=(1, 1),
)
)
except Exception as exc:
console.print(f"[red]Context explanation error: {exc}[/red]")
# T03 (memory wiring): consult after context (so safety can see it in future T04 0002),
# before risk/LLM. Real T02 prefs now available; graceful.
# T03 (0003): pass activation_context so directory/project-bound memory is automatically
# activated based on cwd + git root.
memory = {}
try:
act_ctx = {"cwd": str(Path.cwd())}
if envelope and getattr(envelope, "git", None):
git_info = envelope.git or {}
if git_info.get("workdir"):
act_ctx["git_root"] = git_info["workdir"]
memory = recall_preferences(
".",
activation_context=act_ctx,
kinds=[KIND_REFLECTION, KIND_RETROSPECTION, KIND_INTERACTION_GOAL, "preference"],
)
except Exception:
memory = {"error": "recall failed (graceful degradation)"}
if explain_context and memory.get("items"):
try:
prov = memory.get("provenance", [{}])[0]
sample = ", ".join(i.get("key", "?") for i in memory.get("items", [])[:3])
act_note = ""
if prov.get("activation_context"):
act_note = f" | ctx: {prov['activation_context']}"
body = (
f"Phase: {memory.get('phase')} | {len(memory.get('items', []))} items | "
f"{prov.get('source', 'local')}{act_note}\n"
f"Sample activated: {sample}"
)
refl_line = format_reflection_surfacing(memory, for_explain=True)
if refl_line:
body += f"\n\n[cyan]Reflections:[/cyan] {refl_line}"
console.print(
Panel(
body,
title="Memory Activated (T03)",
border_style="blue",
padding=(0, 1),
)
)
except Exception:
pass
# 2. Risk classification + mandatory confirmation (T03 safety; T04 memory signals)
assessment = classify(user_request, envelope, memory=memory)
if assessment.requires_confirmation:
from rich.table import Table
table = Table(
title=f"Risk Assessment — {assessment.level.value.upper()}",
show_header=False,
border_style="red",
)
table.add_row("Rationale", assessment.rationale)
if assessment.preview:
table.add_row("Preview", assessment.preview)
if assessment.affected_summary:
table.add_row("Would affect", assessment.affected_summary)
table.add_row("Rules", ", ".join(assessment.rules_triggered[:3]))
console.print(table)
if not get_user_confirmation(assessment):
console.print("[yellow]Action cancelled by user. No changes made.[/yellow]")
return
if dry_run:
console.print("[green]--dry-run acknowledged.[/green] No side-effects.")
return
# 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,
)
llm_response = adapter.complete(llm_request)
# 4. Render final user-facing artifact (T06 responsibility; T03 memory surface)
mem_line = ""
if memory.get("items"):
mem_line = f"\n[dim]Memory activated: {len(memory.get('items', []))} items (phase {memory.get('phase')})[/dim]"
refl_line = format_reflection_surfacing(memory, for_explain=False)
if refl_line:
mem_line += f"\n[cyan]{refl_line}[/cyan]"
console.print(
Panel(
f"[bold]Suggestion:[/bold]\n{llm_response.suggestion}\n\n"
f"[dim]{llm_response.explanation}\n"
f"Rationale: {llm_response.rationale}{mem_line}[/dim]",
title="LLM Response (via T04 seam)",
border_style="magenta",
padding=(1, 1),
)
)
console.print(
"[green]✓[/green] Request processed by orchestrator (T02+T03+T04 coordinated by T06)."
)
def run_retrospection(scope: str = ".", limit: int = 8) -> None:
"""Guided retrospection session (T04 of CYA-WP-0003).
Helps the user review recent memory usage in the given scope,
reflect, and record new interaction goals or preferences.
These are stored using the retrospection-aware memory helper.
"""
console.print(
Panel(
"[bold cyan]Retrospection Session[/bold cyan]\n\n"
f"Scope: [green]{scope}[/green]\n"
"We will look at recent memory items and help you reflect.\n"
"Your answers will be stored as durable retrospection memory.",
title="cya retrospect",
border_style="magenta",
padding=(1, 2),
)
)
# Recall recent items, with bias toward retrospection kinds if present
try:
recent = recall_preferences(
scope,
limit=limit,
kinds=[KIND_RETROSPECTION, KIND_INTERACTION_GOAL, "preference"],
)
items = recent.get("items", [])
except Exception as e:
console.print(f"[red]Could not load memory: {e}[/red]")
return
if not items:
console.print(
"[yellow]No memory items found in this scope yet.[/yellow]\n"
"You can create some with normal usage or explicit remembers."
)
return
console.print(
Panel(
"\n".join(
f"• [bold]{item.get('key')}[/bold]: {item.get('value')}"
for item in items[:5]
),
title=f"Recent Memory in {scope} (showing up to 5)",
border_style="blue",
)
)
# Simple guided reflection
console.print("\n[bold]Reflection time[/bold]")
what_worked = typer.prompt(
"What worked well in recent assistance? (short answer or 'skip')",
default="",
show_default=False,
)
if what_worked and what_worked.lower() not in ("skip", "s", ""):
remember_retrospection_outcome(
"what_worked", what_worked, scope=scope
)
console.print("[green]Recorded.[/green]")
what_to_change = typer.prompt(
"What should change in future interactions? (e.g. 'be more concise', 'always show alternatives')",
default="",
show_default=False,
)
if what_to_change and what_to_change.lower() not in ("skip", "s", ""):
remember_retrospection_outcome(
"interaction_goal", what_to_change, scope=scope
)
console.print("[green]Recorded as interaction goal.[/green]")
safety_note = typer.prompt(
"Any standing safety or preference rules for this project? (optional)",
default="",
show_default=False,
)
if safety_note and safety_note.lower() not in ("skip", "s", ""):
remember_retrospection_outcome(
"safety_preference", safety_note, scope=scope
)
console.print("[green]Recorded as safety preference.[/green]")
_capture_reflection_lessons(scope)
_offer_reflection_compaction(scope)
console.print(
Panel(
"Thank you. Your reflections have been stored as retrospection memory.\n"
"They will be preferentially activated in future sessions in this scope.\n\n"
"You can review them anytime with:\n"
f" [bold]cya memory reflections --scope {scope}[/bold]\n"
f" [bold]cya --explain-context \"...\"[/bold] (in this directory)\n"
f" or inspect the JSON files in [cyan]~/.config/cya/memory/[/cyan]",
title="Retrospection Complete",
border_style="green",
padding=(1, 2),
)
)
def _capture_reflection_lessons(scope: str) -> None:
"""Profile 1: guided verbal lesson capture with preview and confirmation."""
want = typer.prompt(
"Capture 13 verbal lessons from this session? (y/n)",
default="n",
show_default=False,
)
if not want or want.strip().lower() in ("n", "no", "skip", "s", ""):
return
answers: dict[str, str] = {}
for prompt_key, label in REFLECTION_CAPTURE_PROMPTS:
answers[prompt_key] = typer.prompt(label, default="", show_default=False)
lessons = collect_lessons_from_answers(answers)
if not lessons:
console.print("[yellow]No lessons captured — nothing stored.[/yellow]")
return
console.print(
Panel(
preview_lessons(lessons),
title="Preview — verbal lessons to save",
border_style="cyan",
)
)
confirm = typer.prompt("Save these lessons? (y/n)", default="n", show_default=False)
if confirm.strip().lower() not in ("y", "yes"):
console.print("[yellow]Lessons discarded — nothing stored.[/yellow]")
return
count = save_reflection_lessons(
lessons,
scope,
provenance=session_provenance(scope),
)
console.print(f"[green]Saved {count} verbal reflection(s) (Profile 1).[/green]")
def _offer_reflection_compaction(scope: str) -> None:
"""Offer explicit merge of near-duplicate reflections in this scope."""
groups = find_duplicate_reflection_groups(scope)
if not groups:
return
console.print(
Panel(
f"Found {len(groups)} group(s) of similar reflections in scope [green]{scope}[/green].\n"
"Compaction is opt-in — nothing is deleted without your confirmation.",
title="Reflection compaction available",
border_style="yellow",
)
)
do_compact = typer.prompt("Review and compact duplicates? (y/n)", default="n", show_default=False)
if do_compact.strip().lower() not in ("y", "yes"):
return
for group in groups:
console.print("\n[bold]Similar reflections:[/bold]")
for item in group:
console.print(f"{item.get('key')}: {item.get('value')}")
keep = typer.prompt(
"Key to keep (or 'skip' this group)",
default=group[0].get("key", ""),
show_default=True,
)
if not keep or keep.strip().lower() in ("skip", "s"):
continue
remove_keys = [i.get("key") for i in group if i.get("key") != keep]
merge = typer.prompt(
"Merged text (Enter to keep existing value of kept key)",
default="",
show_default=False,
)
result = compact_reflections(
scope,
keep_key=keep,
remove_keys=[k for k in remove_keys if k],
merged_value=merge.strip() or None,
)
console.print(
f"[green]Compacted: kept {result['kept']}, removed {len(result['removed'])}[/green]"
)
__all__ = ["handle_request", "run_retrospection"]