1
0

feat: CLI session persistence + tool confirmation gate

- Chat sessions persist across `timmy chat` invocations via Agno SQLite
  (session_id="cli"), fixing context amnesia between turns
- Dangerous tools (shell, write_file, etc.) now prompt for approval in CLI
  instead of silently exiting — uses typer.confirm() + Agno continue_run
- --new flag starts a fresh conversation when needed
- Improved _maybe_file_issues prompt for engineer-quality issue bodies
  (what's happening, expected behavior, suggested fix, acceptance criteria)
- think/status commands also pass session_id for continuity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trip T
2026-03-12 20:55:56 -04:00
parent 350e6f54ff
commit 41d6ebaf6a
3 changed files with 284 additions and 26 deletions

View File

@@ -1,12 +1,20 @@
import logging
import subprocess
import typer
from timmy.agent import create_timmy
from timmy.prompts import STATUS_PROMPT
from timmy.tool_safety import format_action_description, get_impact_level
logger = logging.getLogger(__name__)
app = typer.Typer(help="Timmy — sovereign AI agent")
# Stable session ID — Agno persists conversation history in SQLite keyed on this.
# Every `timmy chat` invocation reuses the same session so context carries over.
_CLI_SESSION_ID = "cli"
# Shared option definitions (reused across commands for consistency).
_BACKEND_OPTION = typer.Option(
None,
@@ -22,6 +30,64 @@ _MODEL_SIZE_OPTION = typer.Option(
)
def _handle_tool_confirmation(agent, run_output, session_id: str):
"""Prompt user to approve/reject dangerous tool calls.
When Agno pauses a run because a tool requires confirmation, this
function displays the action, asks for approval via stdin, and
resumes or rejects the run accordingly.
Returns the final RunOutput after all confirmations are resolved.
"""
max_rounds = 10 # safety limit
for _ in range(max_rounds):
status = getattr(run_output, "status", None)
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
if not is_paused:
return run_output
reqs = getattr(run_output, "active_requirements", None) or []
if not reqs:
return run_output
for req in reqs:
if not getattr(req, "needs_confirmation", False):
continue
te = req.tool_execution
tool_name = getattr(te, "tool_name", "unknown")
tool_args = getattr(te, "tool_args", {}) or {}
description = format_action_description(tool_name, tool_args)
impact = get_impact_level(tool_name)
typer.echo()
typer.echo(typer.style("Tool confirmation required", bold=True))
typer.echo(f" Impact: {impact.upper()}")
typer.echo(f" {description}")
typer.echo()
approved = typer.confirm("Allow this action?", default=False)
if approved:
req.confirm()
logger.info("CLI: approved %s", tool_name)
else:
req.reject(note="User rejected from CLI")
logger.info("CLI: rejected %s", tool_name)
# Resume the run so the agent sees the confirmation result
try:
run_output = agent.continue_run(
run_response=run_output, stream=False, session_id=session_id
)
except Exception as exc:
logger.error("CLI: continue_run failed: %s", exc)
typer.echo(f"Error resuming: {exc}", err=True)
return run_output
return run_output
@app.command()
def tick(
prompt: str | None = typer.Argument(
@@ -48,7 +114,7 @@ def think(
):
"""Ask Timmy to think carefully about a topic."""
timmy = create_timmy(backend=backend, model_size=model_size)
timmy.print_response(f"Think carefully about: {topic}", stream=True)
timmy.print_response(f"Think carefully about: {topic}", stream=True, session_id=_CLI_SESSION_ID)
@app.command()
@@ -56,10 +122,34 @@ def chat(
message: str = typer.Argument(..., help="Message to send"),
backend: str | None = _BACKEND_OPTION,
model_size: str | None = _MODEL_SIZE_OPTION,
new_session: bool = typer.Option(
False,
"--new",
"-n",
help="Start a fresh conversation (ignore prior context)",
),
):
"""Send a message to Timmy."""
"""Send a message to Timmy.
Conversation history persists across invocations. Use --new to start fresh.
"""
import uuid
session_id = str(uuid.uuid4()) if new_session else _CLI_SESSION_ID
timmy = create_timmy(backend=backend, model_size=model_size)
timmy.print_response(message, stream=True)
# Use agent.run() so we can intercept paused runs for tool confirmation.
run_output = timmy.run(message, stream=False, session_id=session_id)
# Handle paused runs — dangerous tools need user approval
run_output = _handle_tool_confirmation(timmy, run_output, session_id)
# Print the final response
content = run_output.content if hasattr(run_output, "content") else str(run_output)
if content:
from timmy.session import _clean_response
typer.echo(_clean_response(content))
@app.command()
@@ -69,7 +159,7 @@ def status(
):
"""Print Timmy's operational status."""
timmy = create_timmy(backend=backend, model_size=model_size)
timmy.print_response(STATUS_PROMPT, stream=False)
timmy.print_response(STATUS_PROMPT, stream=False, session_id=_CLI_SESSION_ID)
@app.command()