forked from Rockachopa/Timmy-time-dashboard
Merge pull request 'claude/sharp-mcnulty' (#13) from claude/sharp-mcnulty into main
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/13
This commit is contained in:
@@ -72,7 +72,6 @@ class GiteaHand:
|
||||
self._token = token or _resolve_token()
|
||||
self._repo = repo or settings.gitea_repo
|
||||
self._timeout = timeout or settings.gitea_timeout
|
||||
self._client = None
|
||||
|
||||
if not self._token:
|
||||
logger.warning(
|
||||
@@ -92,20 +91,24 @@ class GiteaHand:
|
||||
return bool(settings.gitea_enabled and self._token and self._repo)
|
||||
|
||||
def _get_client(self):
|
||||
"""Lazy-initialise the async HTTP client."""
|
||||
"""Create a fresh async HTTP client for the current event loop.
|
||||
|
||||
Always creates a new client rather than caching, because tool
|
||||
functions call us via ``asyncio.run()`` which creates a new loop
|
||||
each time — a cached client from a previous loop would raise
|
||||
"Event loop is closed".
|
||||
"""
|
||||
import httpx
|
||||
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers={
|
||||
"Authorization": f"token {self._token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
return self._client
|
||||
return httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers={
|
||||
"Authorization": f"token {self._token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs) -> GiteaResult:
|
||||
"""Make an API request with full error handling."""
|
||||
@@ -119,8 +122,8 @@ class GiteaHand:
|
||||
error="Gitea not configured (missing token or repo)",
|
||||
)
|
||||
|
||||
client = self._get_client()
|
||||
try:
|
||||
client = self._get_client()
|
||||
resp = await client.request(method, path, **kwargs)
|
||||
latency = (time.time() - start) * 1000
|
||||
|
||||
@@ -155,6 +158,8 @@ class GiteaHand:
|
||||
error=str(exc),
|
||||
latency_ms=latency,
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
# ── Issue operations ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -422,8 +422,15 @@ class ThinkingEngine:
|
||||
"Rules:\n"
|
||||
"- Only include things that could become a real code fix or feature\n"
|
||||
"- Skip vague reflections, philosophical musings, or repeated themes\n"
|
||||
"- Each item needs a specific, descriptive title and body\n"
|
||||
"- Category must be one of: bug, feature, suggestion, maintenance\n\n"
|
||||
"For each item, write an ENGINEER-QUALITY issue:\n"
|
||||
'- "title": A clear, specific title (e.g. "[Memory] MEMORY.md timestamp not updating")\n'
|
||||
'- "body": A detailed body with these sections:\n'
|
||||
" **What's happening:** Describe the current (broken) behavior.\n"
|
||||
" **Expected behavior:** What should happen instead.\n"
|
||||
" **Suggested fix:** Which file(s) to change and what the fix looks like.\n"
|
||||
" **Acceptance criteria:** How to verify the fix works.\n"
|
||||
'- "category": One of bug, feature, suggestion, maintenance\n\n'
|
||||
"Return ONLY a JSON array of objects with keys: "
|
||||
'"title", "body", "category"\n'
|
||||
"Return [] if nothing is actionable.\n\n"
|
||||
|
||||
@@ -199,7 +199,7 @@ async def test_create_issue_success():
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_client.is_closed = False
|
||||
mock_client.aclose = AsyncMock()
|
||||
|
||||
with patch("infrastructure.hands.gitea.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
@@ -208,7 +208,7 @@ async def test_create_issue_success():
|
||||
mock_settings.gitea_repo = "owner/repo"
|
||||
mock_settings.gitea_timeout = 30
|
||||
hand = GiteaHand(token="test-token")
|
||||
hand._client = mock_client
|
||||
hand._get_client = MagicMock(return_value=mock_client)
|
||||
|
||||
result = await hand.create_issue("Test issue", "Body text")
|
||||
assert result.success is True
|
||||
@@ -231,7 +231,7 @@ async def test_create_issue_handles_http_error():
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_client.is_closed = False
|
||||
mock_client.aclose = AsyncMock()
|
||||
|
||||
with patch("infrastructure.hands.gitea.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
@@ -240,7 +240,7 @@ async def test_create_issue_handles_http_error():
|
||||
mock_settings.gitea_repo = "owner/repo"
|
||||
mock_settings.gitea_timeout = 30
|
||||
hand = GiteaHand(token="test-token")
|
||||
hand._client = mock_client
|
||||
hand._get_client = MagicMock(return_value=mock_client)
|
||||
|
||||
result = await hand.create_issue("Test issue")
|
||||
assert result.success is False
|
||||
@@ -254,7 +254,7 @@ async def test_create_issue_handles_connection_error():
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(side_effect=ConnectionError("refused"))
|
||||
mock_client.is_closed = False
|
||||
mock_client.aclose = AsyncMock()
|
||||
|
||||
with patch("infrastructure.hands.gitea.settings") as mock_settings:
|
||||
mock_settings.gitea_enabled = True
|
||||
@@ -263,7 +263,7 @@ async def test_create_issue_handles_connection_error():
|
||||
mock_settings.gitea_repo = "owner/repo"
|
||||
mock_settings.gitea_timeout = 30
|
||||
hand = GiteaHand(token="test-token")
|
||||
hand._client = mock_client
|
||||
hand._get_client = MagicMock(return_value=mock_client)
|
||||
|
||||
result = await hand.create_issue("Test issue")
|
||||
assert result.success is False
|
||||
|
||||
@@ -2,12 +2,17 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from timmy.cli import app
|
||||
from timmy.cli import _CLI_SESSION_ID, _handle_tool_confirmation, app
|
||||
from timmy.prompts import STATUS_PROMPT
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# status command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_status_uses_status_prompt():
|
||||
"""status command must pass STATUS_PROMPT to the agent."""
|
||||
mock_timmy = MagicMock()
|
||||
@@ -15,7 +20,9 @@ def test_status_uses_status_prompt():
|
||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy):
|
||||
runner.invoke(app, ["status"])
|
||||
|
||||
mock_timmy.print_response.assert_called_once_with(STATUS_PROMPT, stream=False)
|
||||
mock_timmy.print_response.assert_called_once_with(
|
||||
STATUS_PROMPT, stream=False, session_id=_CLI_SESSION_ID
|
||||
)
|
||||
|
||||
|
||||
def test_status_does_not_use_inline_string():
|
||||
@@ -29,14 +36,9 @@ def test_status_does_not_use_inline_string():
|
||||
assert call_args[0][0] != "Brief status report — one sentence."
|
||||
|
||||
|
||||
def test_chat_sends_message_to_agent():
|
||||
"""chat command must pass the user message to the agent with streaming."""
|
||||
mock_timmy = MagicMock()
|
||||
|
||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy):
|
||||
runner.invoke(app, ["chat", "Hello Timmy"])
|
||||
|
||||
mock_timmy.print_response.assert_called_once_with("Hello Timmy", stream=True)
|
||||
# ---------------------------------------------------------------------------
|
||||
# think command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_think_sends_topic_to_agent():
|
||||
@@ -47,20 +49,12 @@ def test_think_sends_topic_to_agent():
|
||||
runner.invoke(app, ["think", "Bitcoin self-custody"])
|
||||
|
||||
mock_timmy.print_response.assert_called_once_with(
|
||||
"Think carefully about: Bitcoin self-custody", stream=True
|
||||
"Think carefully about: Bitcoin self-custody",
|
||||
stream=True,
|
||||
session_id=_CLI_SESSION_ID,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_passes_backend_option():
|
||||
"""chat --backend airllm must forward the backend to create_timmy."""
|
||||
mock_timmy = MagicMock()
|
||||
|
||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy) as mock_create:
|
||||
runner.invoke(app, ["chat", "test", "--backend", "airllm"])
|
||||
|
||||
mock_create.assert_called_once_with(backend="airllm", model_size=None)
|
||||
|
||||
|
||||
def test_think_passes_model_size_option():
|
||||
"""think --model-size 70b must forward the model size to create_timmy."""
|
||||
mock_timmy = MagicMock()
|
||||
@@ -69,3 +63,170 @@ def test_think_passes_model_size_option():
|
||||
runner.invoke(app, ["think", "topic", "--model-size", "70b"])
|
||||
|
||||
mock_create.assert_called_once_with(backend=None, model_size="70b")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat command — session persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_uses_session_id():
|
||||
"""chat command must pass the stable CLI session_id to agent.run()."""
|
||||
mock_run_output = MagicMock()
|
||||
mock_run_output.content = "Hello there!"
|
||||
mock_run_output.status = "COMPLETED"
|
||||
mock_run_output.active_requirements = []
|
||||
|
||||
mock_timmy = MagicMock()
|
||||
mock_timmy.run.return_value = mock_run_output
|
||||
|
||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy):
|
||||
result = runner.invoke(app, ["chat", "Hello Timmy"])
|
||||
|
||||
mock_timmy.run.assert_called_once_with("Hello Timmy", stream=False, session_id=_CLI_SESSION_ID)
|
||||
assert result.exit_code == 0
|
||||
assert "Hello there!" in result.output
|
||||
|
||||
|
||||
def test_chat_new_session_uses_unique_id():
|
||||
"""chat --new must use a unique session_id, not the stable one."""
|
||||
mock_run_output = MagicMock()
|
||||
mock_run_output.content = "Fresh start!"
|
||||
mock_run_output.status = "COMPLETED"
|
||||
mock_run_output.active_requirements = []
|
||||
|
||||
mock_timmy = MagicMock()
|
||||
mock_timmy.run.return_value = mock_run_output
|
||||
|
||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy):
|
||||
runner.invoke(app, ["chat", "Hello", "--new"])
|
||||
|
||||
call_args = mock_timmy.run.call_args
|
||||
used_session_id = call_args[1]["session_id"]
|
||||
assert used_session_id != _CLI_SESSION_ID # Must be unique
|
||||
|
||||
|
||||
def test_chat_passes_backend_option():
|
||||
"""chat --backend airllm must forward the backend to create_timmy."""
|
||||
mock_run_output = MagicMock()
|
||||
mock_run_output.content = "OK"
|
||||
mock_run_output.status = "COMPLETED"
|
||||
mock_run_output.active_requirements = []
|
||||
|
||||
mock_timmy = MagicMock()
|
||||
mock_timmy.run.return_value = mock_run_output
|
||||
|
||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy) as mock_create:
|
||||
runner.invoke(app, ["chat", "test", "--backend", "airllm"])
|
||||
|
||||
mock_create.assert_called_once_with(backend="airllm", model_size=None)
|
||||
|
||||
|
||||
def test_chat_cleans_response():
|
||||
"""chat must clean tool-call artifacts from the response."""
|
||||
raw = '{"name": "python", "parameters": {"code": "1+1"}} The answer is 2.'
|
||||
mock_run_output = MagicMock()
|
||||
mock_run_output.content = raw
|
||||
mock_run_output.status = "COMPLETED"
|
||||
mock_run_output.active_requirements = []
|
||||
|
||||
mock_timmy = MagicMock()
|
||||
mock_timmy.run.return_value = mock_run_output
|
||||
|
||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy):
|
||||
result = runner.invoke(app, ["chat", "what is 1+1"])
|
||||
|
||||
# The JSON tool call should be stripped
|
||||
assert '"name": "python"' not in result.output
|
||||
assert "The answer is 2." in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool confirmation gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_paused_run(tool_name="shell", tool_args=None):
|
||||
"""Create a mock paused RunOutput with one requirement."""
|
||||
tool_args = tool_args or {"command": "ls -la"}
|
||||
|
||||
mock_te = MagicMock()
|
||||
mock_te.tool_name = tool_name
|
||||
mock_te.tool_args = tool_args
|
||||
|
||||
mock_req = MagicMock()
|
||||
mock_req.needs_confirmation = True
|
||||
mock_req.tool_execution = mock_te
|
||||
|
||||
mock_run = MagicMock()
|
||||
mock_run.status = "RunStatus.paused"
|
||||
mock_run.active_requirements = [mock_req]
|
||||
|
||||
return mock_run, mock_req
|
||||
|
||||
|
||||
def test_handle_tool_confirmation_approve():
|
||||
"""Approving a tool should call req.confirm() and agent.continue_run()."""
|
||||
paused_run, mock_req = _make_paused_run()
|
||||
|
||||
completed_run = MagicMock()
|
||||
completed_run.status = "COMPLETED"
|
||||
completed_run.active_requirements = []
|
||||
completed_run.content = "Done."
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.continue_run.return_value = completed_run
|
||||
|
||||
# Simulate user typing "y" at the prompt
|
||||
with patch("timmy.cli.typer.confirm", return_value=True):
|
||||
result = _handle_tool_confirmation(mock_agent, paused_run, "cli")
|
||||
|
||||
mock_req.confirm.assert_called_once()
|
||||
mock_agent.continue_run.assert_called_once()
|
||||
assert result.content == "Done."
|
||||
|
||||
|
||||
def test_handle_tool_confirmation_reject():
|
||||
"""Rejecting a tool should call req.reject() and agent.continue_run()."""
|
||||
paused_run, mock_req = _make_paused_run()
|
||||
|
||||
completed_run = MagicMock()
|
||||
completed_run.status = "COMPLETED"
|
||||
completed_run.active_requirements = []
|
||||
completed_run.content = "Action rejected."
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.continue_run.return_value = completed_run
|
||||
|
||||
with patch("timmy.cli.typer.confirm", return_value=False):
|
||||
_handle_tool_confirmation(mock_agent, paused_run, "cli")
|
||||
|
||||
mock_req.reject.assert_called_once()
|
||||
mock_agent.continue_run.assert_called_once()
|
||||
|
||||
|
||||
def test_handle_tool_confirmation_not_paused():
|
||||
"""Non-paused runs should pass through unchanged."""
|
||||
completed_run = MagicMock()
|
||||
completed_run.status = "COMPLETED"
|
||||
completed_run.active_requirements = []
|
||||
|
||||
mock_agent = MagicMock()
|
||||
result = _handle_tool_confirmation(mock_agent, completed_run, "cli")
|
||||
|
||||
assert result is completed_run
|
||||
mock_agent.continue_run.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_tool_confirmation_continue_error():
|
||||
"""Errors in continue_run should be handled gracefully."""
|
||||
paused_run, mock_req = _make_paused_run()
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.continue_run.side_effect = Exception("connection lost")
|
||||
|
||||
with patch("timmy.cli.typer.confirm", return_value=True):
|
||||
result = _handle_tool_confirmation(mock_agent, paused_run, "cli")
|
||||
|
||||
# Should return the original paused run, not crash
|
||||
assert result is paused_run
|
||||
|
||||
Reference in New Issue
Block a user