diff --git a/very-simplified-stack/cognito-backend/README.md b/very-simplified-stack/cognito-backend/README.md index d47ce73..9de67b8 100644 --- a/very-simplified-stack/cognito-backend/README.md +++ b/very-simplified-stack/cognito-backend/README.md @@ -61,6 +61,7 @@ Settings are loaded in the following order of priority: - `app/services/backend_client.py`: Unified async client for Ollama and OpenAI backends. - `app/core/agent_loop.py`: Turning text generation into a tool-executing agent loop. - `app/core/session_manager.py`: Persistence and history management for AI sessions. +- `cli/cognito_cli.py`: Python CLI client for Cognito Agent. - `test-voice-api.ps1`: The main PowerShell profile script containing `cog` and `cogt`. - `Install-CognitoProfile.ps1`: Installer for the PowerShell environment. - `config.example.json`: Template for the user configuration file. @@ -81,6 +82,22 @@ Las sesiones se guardan en `~/.cognito/sessions/` en formato JSONL (append-only) - **Continuidad**: Usa `session_id: "latest"` para continuar automáticamente la conversación más reciente en el `cwd` actual. - **Forking**: Permite clonar una sesión existente para explorar ramas alternativas sin perder el historial original. +### CLI de Python (Fase 3) +El backend incluye un cliente ligero en Python con tres modos de operación: + +- **Modo `print`** (default): Salida interactiva con colores ANSI TrueColor por incertidumbre. + ```bash + python -m cli.cognito_cli "Explica la fotosíntesis" --session-id latest + ``` +- **Modo `json`**: Salida NDJSON para integración con otros scripts. + ```bash + python -m cli.cognito_cli "Lista archivos" --mode json + ``` +- **Modo `rpc`**: JSON-RPC 2.0 sobre stdin/stdout, para integración con procesos de larga duración. + ```bash + python -m cli.cognito_cli --mode rpc + ``` + ### Herramientas Disponibles 1. `read`: Lee archivos del sistema (restringido al `cwd`). 2. `write`: Crea o sobrescribe archivos (requiere `trust`). diff --git a/very-simplified-stack/cognito-backend/cli/__init__.py b/very-simplified-stack/cognito-backend/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/very-simplified-stack/cognito-backend/cli/cognito_cli.py b/very-simplified-stack/cognito-backend/cli/cognito_cli.py new file mode 100644 index 0000000..c2f715a --- /dev/null +++ b/very-simplified-stack/cognito-backend/cli/cognito_cli.py @@ -0,0 +1,91 @@ +import sys +import asyncio +import argparse +import os +import logging +from cli.config import load_config +from cli.http_client import CognitoClient +from cli.modes.print_mode import print_mode +from cli.modes.json_mode import json_mode +from cli.modes.rpc_mode import rpc_mode + +# Configure logging to stderr +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + stream=sys.stderr +) +logger = logging.getLogger("cognito-cli") + +async def main(): + parser = argparse.ArgumentParser(description="Cognito CLI Client") + parser.add_argument("prompt", nargs="?", help="The prompt to send. If missing, reads from stdin.") + parser.add_argument("--mode", choices=["print", "json", "rpc"], default="print", help="Output mode (default: print)") + parser.add_argument("--endpoint", help="Server endpoint URL") + parser.add_argument("--threshold", type=float, help="Uncertainty threshold") + parser.add_argument("--no-color", action="store_true", help="Disable colors") + parser.add_argument("--timeout", type=float, help="Network timeout in seconds") + parser.add_argument("--cwd", help="Workspace directory (default: current directory)") + parser.add_argument("--session-id", help="Session ID to continue (or 'latest')") + parser.add_argument("--color-mode", choices=["full", "threshold", "none"], help="Color mode") + + args = parser.parse_args() + + config = load_config( + cli_endpoint=args.endpoint, + cli_threshold=args.threshold, + cli_no_color=args.no_color, + cli_timeout=args.timeout, + cli_color_mode=args.color_mode + ) + + cwd = os.path.realpath(args.cwd or os.getcwd()) + + try: + async with CognitoClient(config.endpoint, config.timeout) as client: + if args.mode == "rpc": + return await rpc_mode(client, config) + + # For print and json mode, we need a prompt + prompt = args.prompt + if not prompt: + if not sys.stdin.isatty(): + prompt = sys.stdin.read() + + if not prompt: + sys.stderr.write("Error: No prompt provided.\n") + return 1 + + messages = [{"role": "user", "content": prompt}] + event_iterator = client.agent_loop( + messages=messages, + cwd=cwd, + session_id=args.session_id + ) + + if args.mode == "json": + return await json_mode(event_iterator, config) + else: # print + return await print_mode(event_iterator, config) + + except Exception as e: + if args.mode == "rpc": + # For RPC, we already handle errors inside rpc_mode mostly, + # but if it fails before starting the loop: + print(json.dumps({ + "jsonrpc": "2.0", + "error": {"code": -32000, "message": "Internal error", "data": {"detail": str(e)}}, + "id": None + })) + else: + # Check for network errors specifically + import httpx + if isinstance(e, (httpx.ConnectError, httpx.TimeoutException)): + sys.stderr.write(f"Error: no se pudo conectar con {config.endpoint}: {str(e)}\n") + return 2 + else: + sys.stderr.write(f"Error: {str(e)}\n") + return 1 + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/very-simplified-stack/cognito-backend/cli/config.py b/very-simplified-stack/cognito-backend/cli/config.py new file mode 100644 index 0000000..c9cd1bf --- /dev/null +++ b/very-simplified-stack/cognito-backend/cli/config.py @@ -0,0 +1,71 @@ +import os +import json +from pathlib import Path +from dataclasses import dataclass +from typing import Optional + +@dataclass +class CognitoConfig: + endpoint: str + uncertainty_threshold: float + enable_uncertainty: bool + color_mode: str + timeout: float + no_color: bool = False + +def load_config( + cli_endpoint: Optional[str] = None, + cli_threshold: Optional[float] = None, + cli_no_color: bool = False, + cli_timeout: Optional[float] = None, + cli_color_mode: Optional[str] = None +) -> CognitoConfig: + # 1. Defaults + config = { + "Endpoint": "http://localhost:8000", + "UncertaintyThreshold": 0.55, + "EnableUncertainty": True, + "ColorMode": "full", + "Timeout": 120.0 + } + + # 2. config.json + config_path = Path.home() / ".cognito" / "config.json" + if config_path.exists(): + try: + with open(config_path, "r") as f: + file_config = json.load(f) + for k, v in file_config.items(): + if k in config: + config[k] = v + except Exception: + pass + + # 3. Environment variables + if os.getenv("COGNITO_ENDPOINT"): + config["Endpoint"] = os.getenv("COGNITO_ENDPOINT") + if os.getenv("COGNITO_UNCERTAINTY_THRESHOLD"): + config["UncertaintyThreshold"] = float(os.getenv("COGNITO_UNCERTAINTY_THRESHOLD")) + if os.getenv("COGNITO_ENABLE_UNCERTAINTY"): + config["EnableUncertainty"] = os.getenv("COGNITO_ENABLE_UNCERTAINTY").lower() != "false" + if os.getenv("COGNITO_COLOR_MODE"): + config["ColorMode"] = os.getenv("COGNITO_COLOR_MODE") + + # 4. CLI arguments (highest priority) + if cli_endpoint: + config["Endpoint"] = cli_endpoint + if cli_threshold is not None: + config["UncertaintyThreshold"] = cli_threshold + if cli_timeout is not None: + config["Timeout"] = cli_timeout + if cli_color_mode: + config["ColorMode"] = cli_color_mode + + return CognitoConfig( + endpoint=config["Endpoint"], + uncertainty_threshold=config["UncertaintyThreshold"], + enable_uncertainty=config["EnableUncertainty"], + color_mode=config["ColorMode"], + timeout=config["Timeout"], + no_color=cli_no_color or config["ColorMode"] == "none" + ) diff --git a/very-simplified-stack/cognito-backend/cli/http_client.py b/very-simplified-stack/cognito-backend/cli/http_client.py new file mode 100644 index 0000000..9eda206 --- /dev/null +++ b/very-simplified-stack/cognito-backend/cli/http_client.py @@ -0,0 +1,74 @@ +import json +import httpx +import logging +from typing import AsyncIterator, Dict, List, Optional, Any + +logger = logging.getLogger(__name__) + +class CognitoClient: + def __init__(self, endpoint: str, timeout: float = 120.0): + self.endpoint = endpoint.rstrip("/") + self.timeout = httpx.Timeout(timeout, connect=timeout, read=timeout) + self._client = httpx.AsyncClient(timeout=self.timeout) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self._client.aclose() + + async def agent_loop( + self, + messages: List[Dict[str, Any]], + cwd: str, + session_id: Optional[str] = None, + model_params: Optional[Dict[str, Any]] = None, + ) -> AsyncIterator[Dict[str, Any]]: + url = f"{self.endpoint}/api/agent/loop" + payload = { + "messages": messages, + "cwd": cwd, + "session_id": session_id, + "model_params": model_params + } + + async with self._client.stream("POST", url, json=payload) as response: + if response.status_code != 200: + # Handle non-200 responses as errors + try: + error_data = await response.aread() + detail = json.loads(error_data).get("detail", str(error_data)) + except: + detail = response.reason_phrase + raise RuntimeError(f"Server returned {response.status_code}: {detail}") + + async for line in response.aiter_lines(): + if not line.strip(): + continue + if line.startswith("data: "): + data_str = line[6:].strip() + try: + yield json.loads(data_str) + except json.JSONDecodeError: + logger.warning(f"Failed to parse SSE data: {data_str}") + + async def list_sessions(self, cwd: Optional[str] = None) -> List[Dict[str, Any]]: + params = {"cwd": cwd} if cwd else {} + resp = await self._client.get(f"{self.endpoint}/api/agent/sessions", params=params) + resp.raise_for_status() + return resp.json() + + async def get_session(self, session_id: str) -> Dict[str, Any]: + resp = await self._client.get(f"{self.endpoint}/api/agent/sessions/{session_id}") + resp.raise_for_status() + return resp.json() + + async def fork_session(self, session_id: str) -> str: + resp = await self._client.post(f"{self.endpoint}/api/agent/sessions/{session_id}/fork") + resp.raise_for_status() + return resp.json()["session_id"] + + async def health(self) -> Dict[str, Any]: + resp = await self._client.get(f"{self.endpoint}/health") + resp.raise_for_status() + return resp.json() diff --git a/very-simplified-stack/cognito-backend/cli/modes/__init__.py b/very-simplified-stack/cognito-backend/cli/modes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/very-simplified-stack/cognito-backend/cli/modes/json_mode.py b/very-simplified-stack/cognito-backend/cli/modes/json_mode.py new file mode 100644 index 0000000..cac2c45 --- /dev/null +++ b/very-simplified-stack/cognito-backend/cli/modes/json_mode.py @@ -0,0 +1,17 @@ +import sys +import json +from cli.config import CognitoConfig + +async def json_mode(event_iterator, config: CognitoConfig): + exit_code = 0 + async for event in event_iterator: + sys.stdout.write(json.dumps(event) + "\n") + sys.stdout.flush() + + if event.get("type") == "done": + if event.get("stop_reason") != "end_turn": + exit_code = 1 + elif event.get("type") == "error": + exit_code = 1 + + return exit_code diff --git a/very-simplified-stack/cognito-backend/cli/modes/print_mode.py b/very-simplified-stack/cognito-backend/cli/modes/print_mode.py new file mode 100644 index 0000000..96aec52 --- /dev/null +++ b/very-simplified-stack/cognito-backend/cli/modes/print_mode.py @@ -0,0 +1,71 @@ +import sys +from cli.config import CognitoConfig +from typing import Dict, Any + +RESET = "\x1b[0m" + +def get_uncertainty_color(score: float) -> str: + if score < 0.5: + # blue (100, 200, 255) -> amber (255, 200, 60) + t = score / 0.5 + r = int(100 + t * (255 - 100)) + g = 200 + b = int(255 + t * (60 - 255)) + else: + # amber (255, 200, 60) -> red (255, 60, 40) + t = (score - 0.5) / 0.5 + r = 255 + g = int(200 + t * (60 - 200)) + b = int(60 + t * (40 - 60)) + return f"\x1b[38;2;{r};{g};{b}m" + +async def print_mode(event_iterator, config: CognitoConfig): + async for event in event_iterator: + event_type = event.get("type") + + if event_type == "session_info": + sys.stderr.write(f"[session: {event.get('session_id')}]\n") + sys.stderr.flush() + + elif event_type == "text_delta": + content = event.get("content", "") + uncertainty = event.get("uncertainty") + + if not config.no_color and config.enable_uncertainty and uncertainty is not None: + if config.color_mode == "threshold": + if uncertainty >= config.uncertainty_threshold: + sys.stdout.write(f"\x1b[31m{content}{RESET}") + else: + sys.stdout.write(content) + else: # full + color = get_uncertainty_color(uncertainty) + sys.stdout.write(f"{color}{content}{RESET}") + else: + sys.stdout.write(content) + sys.stdout.flush() + + elif event_type == "tool_call": + sys.stderr.write(f"→ ejecutando {event.get('tool_name')}({event.get('arguments')})\n") + sys.stderr.flush() + + elif event_type == "tool_result": + output = event.get("output", "") + size = len(output) + sys.stderr.write(f"← {event.get('tool_name')}: {size} bytes\n") + sys.stderr.flush() + + elif event_type == "done": + stop_reason = event.get("stop_reason") + if stop_reason != "end_turn": + sys.stderr.write(f"[done: {stop_reason}]\n") + if event.get("error_message"): + sys.stderr.write(f"Error: {event.get('error_message')}\n") + sys.stderr.flush() + return 0 if stop_reason == "end_turn" else 1 + + elif event_type == "error": + sys.stderr.write(f"Error: {event.get('message')}\n") + sys.stderr.flush() + return 1 + + return 0 diff --git a/very-simplified-stack/cognito-backend/cli/modes/rpc_mode.py b/very-simplified-stack/cognito-backend/cli/modes/rpc_mode.py new file mode 100644 index 0000000..72b9d57 --- /dev/null +++ b/very-simplified-stack/cognito-backend/cli/modes/rpc_mode.py @@ -0,0 +1,140 @@ +import sys +import json +import asyncio +import logging +from cli.config import CognitoConfig +from cli.http_client import CognitoClient + +logger = logging.getLogger(__name__) + +async def rpc_mode(client: CognitoClient, config: CognitoConfig): + # Sequential JSON-RPC 2.0 loop + loop = asyncio.get_event_loop() + + while True: + line = await loop.run_in_executor(None, sys.stdin.readline) + if not line: + break + + try: + request = json.loads(line) + except json.JSONDecodeError: + send_error(None, -32700, "Parse error") + continue + + if not isinstance(request, dict) or "jsonrpc" not in request or "method" not in request: + send_error(request.get("id") if isinstance(request, dict) else None, -32600, "Invalid Request") + continue + + method = request.get("method") + params = request.get("params", {}) + request_id = request.get("id") + + try: + if method == "health": + result = await client.health() + send_result(request_id, result) + + elif method == "agent.sessions.list": + result = await client.list_sessions(cwd=params.get("cwd")) + send_result(request_id, result) + + elif method == "agent.sessions.get": + sid = params.get("session_id") + if not sid: + send_error(request_id, -32602, "Invalid params: session_id required") + else: + result = await client.get_session(sid) + send_result(request_id, result) + + elif method == "agent.sessions.fork": + sid = params.get("session_id") + if not sid: + send_error(request_id, -32602, "Invalid params: session_id required") + else: + result = await client.fork_session(sid) + send_result(request_id, {"session_id": result}) + + elif method == "agent.loop": + messages = params.get("messages") + cwd = params.get("cwd") + if not messages or not cwd: + send_error(request_id, -32602, "Invalid params: messages and cwd required") + continue + + try: + session_id = None + stop_reason = "unknown" + async for event in client.agent_loop( + messages=messages, + cwd=cwd, + session_id=params.get("session_id"), + model_params=params.get("model_params") + ): + if event.get("type") == "session_info": + session_id = event.get("session_id") + elif event.get("type") == "done": + stop_reason = event.get("stop_reason") + if stop_reason == "error": + # We'll send the result anyway but we could handle differently + pass + + # Send notification for each event + send_notification("agent.event", { + "request_id": request_id, + **event + }) + + if stop_reason == "error": + # The actual ErrorEvent or DoneEvent with error will have been sent as notification + pass + + send_result(request_id, { + "session_id": session_id, + "stop_reason": stop_reason + }) + except Exception as e: + logger.error(f"Upstream error in agent.loop: {e}", exc_info=True) + send_error(request_id, -32001, "Upstream error", {"detail": str(e)}) + + else: + send_error(request_id, -32601, "Method not found") + + except Exception as e: + logger.error(f"Internal error processing {method}: {e}", exc_info=True) + send_error(request_id, -32000, "Backend unreachable", {"detail": str(e)}) + + return 0 + +def send_result(request_id, result): + if request_id is None: return + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + +def send_error(request_id, code, message, data=None): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": code, + "message": message + } + } + if data: + response["error"]["data"] = data + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + +def send_notification(method, params): + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + sys.stdout.write(json.dumps(notification) + "\n") + sys.stdout.flush() diff --git a/very-simplified-stack/cognito-backend/tests/test_cli_config.py b/very-simplified-stack/cognito-backend/tests/test_cli_config.py new file mode 100644 index 0000000..4aff1d2 --- /dev/null +++ b/very-simplified-stack/cognito-backend/tests/test_cli_config.py @@ -0,0 +1,43 @@ +import os +import json +import tempfile +from pathlib import Path +from cli.config import load_config + +def test_load_config_defaults(): + config = load_config() + assert config.endpoint == "http://localhost:8000" + assert config.uncertainty_threshold == 0.55 + assert config.enable_uncertainty is True + +def test_load_config_file(monkeypatch): + with tempfile.TemporaryDirectory() as tmpdir: + tmp_home = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp_home) + + cognito_dir = tmp_home / ".cognito" + cognito_dir.mkdir() + config_file = cognito_dir / "config.json" + config_file.write_text(json.dumps({ + "Endpoint": "http://remote:9000", + "UncertaintyThreshold": 0.8, + "ColorMode": "threshold" + })) + + config = load_config() + assert config.endpoint == "http://remote:9000" + assert config.uncertainty_threshold == 0.8 + assert config.color_mode == "threshold" + +def test_load_config_env(monkeypatch): + monkeypatch.setenv("COGNITO_ENDPOINT", "http://env:7000") + monkeypatch.setenv("COGNITO_UNCERTAINTY_THRESHOLD", "0.4") + + config = load_config() + assert config.endpoint == "http://env:7000" + assert config.uncertainty_threshold == 0.4 + +def test_load_config_cli(): + config = load_config(cli_endpoint="http://cli:6000", cli_threshold=0.1) + assert config.endpoint == "http://cli:6000" + assert config.uncertainty_threshold == 0.1 diff --git a/very-simplified-stack/cognito-backend/tests/test_cli_http_client.py b/very-simplified-stack/cognito-backend/tests/test_cli_http_client.py new file mode 100644 index 0000000..f154420 --- /dev/null +++ b/very-simplified-stack/cognito-backend/tests/test_cli_http_client.py @@ -0,0 +1,42 @@ +import pytest +import json +import httpx +from cli.http_client import CognitoClient + +@pytest.mark.asyncio +async def test_client_agent_loop(respx_mock): + # respx is great for mocking httpx + # If not available, we can mock the client itself, but let's try to mock the network + + endpoint = "http://localhost:8000" + sse_content = ( + "data: {\"type\": \"session_info\", \"session_id\": \"s1\", \"is_new\": true}\n\n" + "data: {\"type\": \"text_delta\", \"content\": \"Hello\"}\n\n" + "data: {\"type\": \"done\", \"stop_reason\": \"end_turn\"}\n\n" + ) + + respx_mock.post(f"{endpoint}/api/agent/loop").return_value = httpx.Response( + 200, + content=sse_content, + headers={"Content-Type": "text/event-stream"} + ) + + async with CognitoClient(endpoint) as client: + events = [] + async for event in client.agent_loop(messages=[], cwd="/tmp"): + events.append(event) + + assert len(events) == 3 + assert events[0]["type"] == "session_info" + assert events[1]["content"] == "Hello" + +@pytest.mark.asyncio +async def test_client_sessions(respx_mock): + endpoint = "http://localhost:8000" + respx_mock.get(f"{endpoint}/api/agent/sessions").return_value = httpx.Response(200, json=[{"session_id": "s1"}]) + + async with CognitoClient(endpoint) as client: + sessions = await client.list_sessions() + + assert len(sessions) == 1 + assert sessions[0]["session_id"] == "s1" diff --git a/very-simplified-stack/cognito-backend/tests/test_cli_modes.py b/very-simplified-stack/cognito-backend/tests/test_cli_modes.py new file mode 100644 index 0000000..6d1ebb2 --- /dev/null +++ b/very-simplified-stack/cognito-backend/tests/test_cli_modes.py @@ -0,0 +1,57 @@ +import pytest +import json +import io +from unittest.mock import MagicMock, AsyncMock +from cli.modes.print_mode import print_mode, get_uncertainty_color +from cli.modes.json_mode import json_mode +from cli.modes.rpc_mode import rpc_mode +from cli.config import CognitoConfig + +def test_uncertainty_color(): + # Blue-ish + c1 = get_uncertainty_color(0.1) + # 0.1 / 0.5 = 0.2 + # R = 100 + 0.2*(255-100) = 100 + 31 = 131 + # G = 200 + # B = 255 + 0.2*(60-255) = 255 - 39 = 216 + assert "\x1b[38;2;131;200;216m" == c1 + + # Red-ish + c2 = get_uncertainty_color(0.9) + # (0.9 - 0.5) / 0.5 = 0.8 + # R = 255 + # G = 200 + 0.8*(60-200) = 200 - 112 = 88 + # B = 60 + 0.8*(40-60) = 60 - 16 = 44 + assert "\x1b[38;2;255;88;44m" == c2 + +@pytest.mark.asyncio +async def test_json_mode(capsys): + async def event_iter(): + yield {"type": "text_delta", "content": "hi"} + yield {"type": "done", "stop_reason": "end_turn"} + + config = MagicMock(spec=CognitoConfig) + code = await json_mode(event_iter(), config) + + out, err = capsys.readouterr() + lines = out.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0])["content"] == "hi" + assert code == 0 + +@pytest.mark.asyncio +async def test_rpc_mode(monkeypatch, capsys): + # Mock stdin + req1 = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "health"}) + monkeypatch.setattr("sys.stdin", io.StringIO(req1 + "\n")) + + client = MagicMock() + client.health = AsyncMock(return_value={"status": "ok"}) + config = MagicMock(spec=CognitoConfig) + + await rpc_mode(client, config) + + out, err = capsys.readouterr() + resp = json.loads(out.strip()) + assert resp["id"] == 1 + assert resp["result"]["status"] == "ok"