Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions amplifier_app_cli/commands/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,17 @@ def _prepare_resume_context(
project_slug=project_slug,
)

# Resume-time provider-mismatch guard (issue #208). Advisory: warns on a
# cross-provider resume and, on a TTY, lets the user abort. Non-interactive
# resumes only log and proceed, so automation is never blocked.
from ..provider_guard import check_resume_provider

if not check_resume_provider(
session_id, config_data, console, base_dir=store.base_dir
):
console.print("[yellow]Resume cancelled.[/yellow]")
raise SystemExit(0)

search_paths = get_module_search_paths()

# Determine active_bundle for display
Expand Down Expand Up @@ -1150,6 +1161,25 @@ def sessions_cleanup(days: int, force: bool):
f"[green]✓[/green] Removed {removed} sessions older than {cutoff:%Y-%m-%d}"
)

@session.command(name="diagnose")
@click.argument("session_id")
def sessions_diagnose(session_id: str):
"""Diagnose a session's transcript health (read-only)."""
_diagnose_session(SessionStore(), session_id)

@session.command(name="repair")
@click.argument("session_id")
def sessions_repair(session_id: str):
"""Repair structural damage in a session's transcript.

Fixes tool-call damage (orphaned tool calls, ordering violations,
incomplete turns) using the COMPLETE strategy. Writes a timestamped
backup before any change and is idempotent (a healthy session is a
no-op). Does NOT repair cross-provider thinking-block damage -- that is
handled at the provider edge (#207).
"""
_repair_session(SessionStore(), session_id)

# Register interactive resume on root CLI (not session subgroup)
@cli.command(name="resume")
@click.argument("session_id", required=False, default=None)
Expand Down Expand Up @@ -1286,6 +1316,95 @@ def _get_session_display_info(store: SessionStore, session_id: str) -> dict:
return info


_FOUNDATION_REPAIR_HINT = (
"Session repair tooling requires a newer amplifier-foundation than is "
"installed. Update with: uv lock --upgrade-package amplifier-foundation"
)


def _diagnose_session(store: SessionStore, session_id: str) -> None:
"""Print transcript health for a session (read-only)."""
try:
full_id = store.find_session(session_id, top_level_only=False)
except FileNotFoundError:
console.print(f"[red]Error:[/red] No session found matching '{session_id}'")
sys.exit(1)
except ValueError as e:
console.print(f"[red]Error:[/red] {escape_markup(e)}")
sys.exit(1)

try:
from amplifier_foundation.session import session_info
except ImportError:
console.print(f"[red]Error:[/red] {_FOUNDATION_REPAIR_HINT}")
sys.exit(1)

info = session_info(store.base_dir / full_id)
if info.get("status") == "healthy":
console.print(
f"[green]✓[/green] Session {full_id[:8]} is healthy — no repair needed."
)
return
modes = ", ".join(info.get("failure_modes", [])) or "unknown"
console.print(f"[yellow]⚠[/yellow] Session {full_id[:8]} is broken: {modes}")
console.print(f" Repair with: [cyan]amplifier session repair {full_id[:8]}[/cyan]")


def _repair_session(store: SessionStore, session_id: str) -> None:
"""Repair tool-call structural damage in a session transcript (COMPLETE strategy)."""
try:
full_id = store.find_session(session_id, top_level_only=False)
except FileNotFoundError:
console.print(f"[red]Error:[/red] No session found matching '{session_id}'")
sys.exit(1)
except ValueError as e:
console.print(f"[red]Error:[/red] {escape_markup(e)}")
sys.exit(1)

try:
from amplifier_foundation.session import (
TRANSCRIPT_FILENAME,
backup,
diagnose_transcript,
load_transcript_with_lines,
repair_transcript,
write_transcript,
)
except ImportError:
console.print(f"[red]Error:[/red] {_FOUNDATION_REPAIR_HINT}")
sys.exit(1)

session_dir = store.base_dir / full_id
entries = load_transcript_with_lines(session_dir)
diagnosis = diagnose_transcript(entries)
if diagnosis["status"] != "broken":
console.print(
f"[green]✓[/green] Session {full_id[:8]} is already healthy — nothing to repair."
)
return

backup_path = backup(session_dir / TRANSCRIPT_FILENAME, "repair")
repaired = repair_transcript(entries, diagnosis)
write_transcript(session_dir, repaired)

# Self-verify by re-diagnosing the written file.
verify = diagnose_transcript(load_transcript_with_lines(session_dir))

console.print(f"[green]✓[/green] Repaired session {full_id[:8]}")
console.print(
f" Fixed: {', '.join(diagnosis.get('failure_modes', [])) or 'structural issues'}"
)
if backup_path:
console.print(f" Backup: {backup_path}")
if verify["status"] == "healthy":
console.print(" Verified: transcript is now healthy")
else:
console.print(
f" [yellow]⚠ Still broken after repair: "
f"{', '.join(verify.get('failure_modes', [])) or 'unknown'}[/yellow]"
)


def _interactive_resume_impl(
ctx: click.Context,
limit: int,
Expand Down
181 changes: 181 additions & 0 deletions amplifier_app_cli/provider_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Resume-time provider-mismatch guard (issue #208, Option A).

Warns when a session is resumed under a different LLM provider than the one
that last wrote to it. Cross-provider resumes can brick a session because saved
history may carry provider-specific content blocks (reasoning / thinking blocks)
that the new provider's API rejects (see #206 / #207).

The last-writing provider is derived from the session's persisted events.jsonl:
the last ``llm:response`` event records ``data.provider`` and ``data.model``.
This mirrors the memory-safe, line-by-line read in
``cost_history.sum_prior_cost_usd`` (events lines can be very large).

Provider mismatch is the dangerous axis. Model-only changes within one provider
are safe and stay silent.
"""

from __future__ import annotations

import json
import logging
import sys
from pathlib import Path

logger = logging.getLogger(__name__)

_LLM_RESPONSE_EVENT = "llm:response"


def last_writing_provider(events_path: Path) -> tuple[str | None, str | None]:
"""Return (provider, model) from the LAST ``llm:response`` event.

Returns (None, None) when the file is missing/unreadable or has no
``llm:response`` events. Reads one line at a time with a cheap substring
pre-filter so huge lines are never all held in memory. Never raises.
"""
if not events_path.is_file():
return (None, None)
provider: str | None = None
model: str | None = None
try:
with events_path.open("r", encoding="utf-8") as handle:
for line in handle:
if _LLM_RESPONSE_EVENT not in line:
continue
try:
event = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
if (
not isinstance(event, dict)
or event.get("event") != _LLM_RESPONSE_EVENT
):
continue
data = event.get("data")
if not isinstance(data, dict):
continue
p = data.get("provider")
if isinstance(p, str) and p:
provider = p
m = data.get("model")
model = m if isinstance(m, str) and m else None
except OSError:
logger.debug("Provider guard: could not read %s", events_path, exc_info=True)
return (None, None)
return (provider, model)


def _first_provider(config_data: dict) -> dict | None:
providers = config_data.get("providers")
if isinstance(providers, list) and providers and isinstance(providers[0], dict):
return providers[0]
return None


def active_provider_aliases(config_data: dict) -> set[str]:
"""Lower-cased identity aliases the resuming session will write under.

Union of {module, module without the ``provider-`` prefix, id, instance_id}
so the events ``data.provider`` short-name matches whichever form the
provider self-reports. Empty set when no providers are resolved.
"""
first = _first_provider(config_data)
if first is None:
return set()
aliases: set[str] = set()
module = first.get("module")
if isinstance(module, str) and module:
aliases.add(module.lower())
aliases.add(module.removeprefix("provider-").lower())
for key in ("instance_id", "id"):
val = first.get(key)
if isinstance(val, str) and val:
aliases.add(val.lower())
return aliases


def active_provider_display(config_data: dict) -> str:
first = _first_provider(config_data)
module = (first or {}).get("module")
if isinstance(module, str) and module:
return module.removeprefix("provider-")
return "the active provider"


def active_model(config_data: dict) -> str | None:
first = _first_provider(config_data)
cfg = (first or {}).get("config")
if isinstance(cfg, dict):
m = cfg.get("model") or cfg.get("default_model")
return m if isinstance(m, str) and m else None
return None


def _short(model: str | None) -> str:
"""Match the display convention in _display_session_history (strip vendor prefix)."""
if not model:
return "?"
return model.split("/")[-1]


def check_resume_provider(
session_id: str,
config_data: dict,
console,
*,
base_dir: Path,
is_tty: bool | None = None,
) -> bool:
"""Warn on provider mismatch at resume. Return True to proceed, False to abort.

Decision table:
- no last-writing provider (no llm:response events) -> True (silent)
- active provider unknown (no resolved providers) -> True (silent)
- last provider matches an active alias -> True (silent; model-only
changes are safe)
- mismatch + non-interactive -> warn to log, True (never
block automation)
- mismatch + interactive TTY -> warn + confirm; return answer
"""
last_provider, last_model = last_writing_provider(
base_dir / session_id / "events.jsonl"
)
if not last_provider:
return True
aliases = active_provider_aliases(config_data)
if not aliases or last_provider.lower() in aliases:
return True

active_name = active_provider_display(config_data)
active_mdl = _short(active_model(config_data))
last_mdl = _short(last_model)
if is_tty is None:
is_tty = sys.stdin.isatty()

console.print(
"[yellow]⚠ Provider mismatch on resume[/yellow]\n"
f" Last written by: [bold]{last_provider}[/bold] ({last_mdl})\n"
f" Resuming with: [bold]{active_name}[/bold] ({active_mdl})\n"
" [dim]Switching providers mid-session can fail if the saved history "
"contains provider-specific content blocks (e.g. reasoning/thinking blocks).[/dim]"
)
if not is_tty:
logger.warning(
"Provider mismatch on resume (session %s): last=%s active=%s; "
"proceeding (non-interactive).",
session_id,
last_provider,
active_name,
)
return True
answer = console.input(" Proceed anyway? [y/N]: ")
return answer.strip().lower() in ("y", "yes")


__all__ = [
"last_writing_provider",
"active_provider_aliases",
"active_provider_display",
"active_model",
"check_resume_provider",
]
Loading