forked from Rockachopa/Timmy-time-dashboard
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user