diff --git a/.gitignore b/.gitignore index d2b9df22..c9359d64 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ app/config/settings.json **/USER.md **/onboarding_config.json **/config.json +!build_template.py \ No newline at end of file diff --git a/agent_core/__init__.py b/agent_core/__init__.py index ee6a3fd6..d0757090 100644 --- a/agent_core/__init__.py +++ b/agent_core/__init__.py @@ -75,6 +75,7 @@ get_credentials, has_embedded_credentials, run_oauth_flow, + run_oauth_flow_async, ) from agent_core.core.config import ( ConfigRegistry, @@ -312,6 +313,7 @@ "get_credentials", "has_embedded_credentials", "run_oauth_flow", + "run_oauth_flow_async", # Config "ConfigRegistry", "get_workspace_root", diff --git a/agent_core/core/credentials/__init__.py b/agent_core/core/credentials/__init__.py index 39200ffc..055a6c77 100644 --- a/agent_core/core/credentials/__init__.py +++ b/agent_core/core/credentials/__init__.py @@ -8,7 +8,7 @@ encode_credential, generate_credentials_block, ) -from agent_core.core.credentials.oauth_server import run_oauth_flow +from agent_core.core.credentials.oauth_server import run_oauth_flow, run_oauth_flow_async __all__ = [ "get_credential", @@ -17,4 +17,5 @@ "encode_credential", "generate_credentials_block", "run_oauth_flow", + "run_oauth_flow_async", ] diff --git a/agent_core/core/credentials/oauth_server.py b/agent_core/core/credentials/oauth_server.py index ac9f4770..9d8a701f 100644 --- a/agent_core/core/credentials/oauth_server.py +++ b/agent_core/core/credentials/oauth_server.py @@ -16,8 +16,12 @@ # HTTPS (for Slack and other providers requiring https redirect URIs) code, error = run_oauth_flow("https://slack.com/oauth/...", use_https=True) + + # Async version with cancellation support (recommended for UI contexts) + code, error = await run_oauth_flow_async("https://provider.com/oauth/...") """ +import asyncio import ipaddress import logging import os @@ -29,7 +33,7 @@ from datetime import datetime, timedelta, timezone from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs -from typing import Optional, Tuple +from typing import Any, Dict, Optional, Tuple logger = logging.getLogger(__name__) @@ -104,58 +108,78 @@ def _cleanup_files(*paths: str) -> None: pass -class _OAuthCallbackHandler(BaseHTTPRequestHandler): - """Handler for OAuth callback requests.""" - - code: Optional[str] = None - state: Optional[str] = None - error: Optional[str] = None - - def do_GET(self): - """Handle GET request from OAuth callback.""" - params = parse_qs(urlparse(self.path).query) - _OAuthCallbackHandler.code = params.get("code", [None])[0] - _OAuthCallbackHandler.state = params.get("state", [None])[0] - _OAuthCallbackHandler.error = params.get("error", [None])[0] +def _make_callback_handler(result_holder: Dict[str, Any]): + """ + Create a callback handler class that stores results in the provided dict. - self.send_response(200) - self.send_header("Content-Type", "text/html") - self.end_headers() - if _OAuthCallbackHandler.code: - self.wfile.write( - b"

Authorization successful!

You can close this tab.

" - ) - else: - self.wfile.write( - f"

Failed

{_OAuthCallbackHandler.error}

".encode() - ) + This avoids class-level state that would be shared across OAuth flows. + """ + class _OAuthCallbackHandler(BaseHTTPRequestHandler): + """Handler for OAuth callback requests.""" + + def do_GET(self): + """Handle GET request from OAuth callback.""" + params = parse_qs(urlparse(self.path).query) + result_holder["code"] = params.get("code", [None])[0] + result_holder["state"] = params.get("state", [None])[0] + result_holder["error"] = params.get("error", [None])[0] + + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + if result_holder["code"]: + self.wfile.write( + b"

Authorization successful!

You can close this tab.

" + ) + else: + self.wfile.write( + f"

Failed

{result_holder['error']}

".encode() + ) + + def log_message(self, format, *args): + """Suppress default HTTP server logging.""" + pass - def log_message(self, format, *args): - """Suppress default HTTP server logging.""" - pass + return _OAuthCallbackHandler -def _serve_until_code(server: HTTPServer, deadline: float) -> None: +def _serve_until_code( + server: HTTPServer, + deadline: float, + result_holder: Dict[str, Any], + cancel_event: Optional[threading.Event] = None, +) -> None: """ - Handle requests in a loop until we capture the OAuth code/error or timeout. + Handle requests in a loop until we capture the OAuth code/error, timeout, or cancelled. A single handle_request() can be consumed by TLS handshake failures, favicon requests, browser pre-connects, etc. Looping ensures the server stays alive for the actual callback. """ while time.time() < deadline: - remaining = max(0.5, deadline - time.time()) - server.timeout = min(remaining, 2.0) + # Check for cancellation + if cancel_event and cancel_event.is_set(): + logger.debug("[OAUTH] Cancellation requested, stopping server") + break + + remaining = max(0.1, deadline - time.time()) + # Use shorter timeout (0.5s) for responsive cancellation checking + server.timeout = min(remaining, 0.5) try: server.handle_request() except Exception as e: logger.debug(f"[OAUTH] handle_request error (will retry): {e}") - if _OAuthCallbackHandler.code or _OAuthCallbackHandler.error: + + if result_holder.get("code") or result_holder.get("error"): break def run_oauth_flow( - auth_url: str, port: int = 8765, timeout: int = 120, use_https: bool = False + auth_url: str, + port: int = 8765, + timeout: int = 120, + use_https: bool = False, + cancel_event: Optional[threading.Event] = None, ) -> Tuple[Optional[str], Optional[str]]: """ Open browser for OAuth, wait for callback. @@ -167,17 +191,27 @@ def run_oauth_flow( use_https: If True, serve HTTPS with a self-signed cert. Required for providers like Slack that reject http:// redirect URIs. Default False (plain HTTP — works with Google, Notion, etc.). + cancel_event: Optional threading.Event to signal cancellation. + When set, the OAuth flow will stop and return a cancellation error. Returns: Tuple of (code, error_message): - On success: (authorization_code, None) - On failure: (None, error_message) """ - _OAuthCallbackHandler.code = None - _OAuthCallbackHandler.state = None - _OAuthCallbackHandler.error = None + # Check for early cancellation + if cancel_event and cancel_event.is_set(): + return None, "OAuth cancelled" - server = HTTPServer(("127.0.0.1", port), _OAuthCallbackHandler) + # Use instance-level result holder instead of class-level state + result_holder: Dict[str, Any] = {"code": None, "state": None, "error": None} + handler_class = _make_callback_handler(result_holder) + + try: + server = HTTPServer(("127.0.0.1", port), handler_class) + except OSError as e: + # Port already in use + return None, f"Failed to start OAuth server: {e}" if use_https: cert_path = key_path = None @@ -198,21 +232,85 @@ def run_oauth_flow( deadline = time.time() + timeout thread = threading.Thread( - target=_serve_until_code, args=(server, deadline), daemon=True + target=_serve_until_code, + args=(server, deadline, result_holder, cancel_event), + daemon=True ) thread.start() + # Check cancellation before opening browser + if cancel_event and cancel_event.is_set(): + server.server_close() + return None, "OAuth cancelled" + try: webbrowser.open(auth_url) except Exception: server.server_close() return None, f"Could not open browser. Visit manually:\n{auth_url}" - thread.join(timeout=timeout) + # Wait for thread with periodic cancellation checks + while thread.is_alive(): + thread.join(timeout=0.5) + if cancel_event and cancel_event.is_set(): + logger.debug("[OAUTH] Cancellation detected during wait") + break + server.server_close() - if _OAuthCallbackHandler.error: - return None, _OAuthCallbackHandler.error - if _OAuthCallbackHandler.code: - return _OAuthCallbackHandler.code, None + # Check cancellation first + if cancel_event and cancel_event.is_set(): + return None, "OAuth cancelled" + + if result_holder.get("error"): + return None, result_holder["error"] + if result_holder.get("code"): + return result_holder["code"], None return None, "OAuth timed out." + + +async def run_oauth_flow_async( + auth_url: str, + port: int = 8765, + timeout: int = 120, + use_https: bool = False, +) -> Tuple[Optional[str], Optional[str]]: + """ + Async version of run_oauth_flow with proper cancellation support. + + This function runs the OAuth flow in a thread executor and properly handles + asyncio task cancellation by signaling the OAuth server to stop. + + Args: + auth_url: The full OAuth authorization URL to open. + port: Local port for callback server (default: 8765). + timeout: Seconds to wait for callback (default: 120). + use_https: If True, serve HTTPS with a self-signed cert. + + Returns: + Tuple of (code, error_message): + - On success: (authorization_code, None) + - On failure: (None, error_message) + + Raises: + asyncio.CancelledError: If the task is cancelled (after signaling OAuth to stop) + """ + cancel_event = threading.Event() + loop = asyncio.get_event_loop() + + def run_flow(): + return run_oauth_flow( + auth_url=auth_url, + port=port, + timeout=timeout, + use_https=use_https, + cancel_event=cancel_event, + ) + + try: + return await loop.run_in_executor(None, run_flow) + except asyncio.CancelledError: + # Signal the OAuth server to stop + cancel_event.set() + logger.debug("[OAUTH] Async task cancelled, signaled OAuth server to stop") + raise diff --git a/agent_core/core/database_interface.py b/agent_core/core/database_interface.py index 69b676a5..98653968 100644 --- a/agent_core/core/database_interface.py +++ b/agent_core/core/database_interface.py @@ -7,17 +7,14 @@ from __future__ import annotations -import asyncio import datetime import json import re -from dataclasses import asdict from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Dict, List, Optional from agent_core.utils.logger import logger -from agent_core.core.task.task import Task from agent_core.core.action_framework.registry import registry_instance from agent_core.core.action_framework.loader import load_actions_from_directories @@ -30,32 +27,27 @@ def __init__( *, data_dir: str = "app/data", chroma_path: str = "./chroma_db", - log_file: Optional[str] = None, ) -> None: """ Initialize storage directories for agent data. - The constructor sets up filesystem paths for logs, actions, task + The constructor sets up filesystem paths for actions, task documents, and agent info. Actions are loaded from directories into the in-memory registry. Args: - data_dir: Base directory used to persist logs and JSON artifacts. + data_dir: Base directory used to persist JSON artifacts. chroma_path: Unused (kept for backward compatibility). - log_file: Optional explicit log file path; defaults to - ``/agent_logs.txt`` when omitted. """ self.data_dir = Path(data_dir) self.data_dir.mkdir(parents=True, exist_ok=True) - self.log_file_path = Path(log_file) if log_file else self.data_dir / "agent_logs.txt" self.actions_dir = self.data_dir / "action" self.task_docs_dir = self.data_dir / "task_document" self.agent_info_path = self.data_dir / "agent_info.json" self.actions_dir.mkdir(parents=True, exist_ok=True) self.task_docs_dir.mkdir(parents=True, exist_ok=True) - self.log_file_path.touch(exist_ok=True) if not self.agent_info_path.exists(): self.agent_info_path.write_text("{}", encoding="utf-8") @@ -67,374 +59,6 @@ def __init__( action_names = [a.get("name") for a in actions if a.get("name")] logger.info(f"Action registry loaded. {len(action_names)} actions available: [{', '.join(sorted(action_names))}]") - # ------------------------------------------------------------------ - # Log helpers - # ------------------------------------------------------------------ - def _load_log_entries(self) -> List[Dict[str, Any]]: - entries: List[Dict[str, Any]] = [] - try: - with self.log_file_path.open("r", encoding="utf-8") as handle: - for line in handle: - line = line.strip() - if not line: - continue - try: - entries.append(json.loads(line)) - except json.JSONDecodeError: - logger.warning(f"[LOG PARSE] Skipping malformed line in {self.log_file_path}") - except FileNotFoundError: - pass - return entries - - def _write_log_entries(self, entries: Iterable[Dict[str, Any]]) -> None: - with self.log_file_path.open("w", encoding="utf-8") as handle: - for entry in entries: - handle.write(json.dumps(entry, default=str) + "\n") - - def _append_log_entry(self, entry: Dict[str, Any]) -> None: - with self.log_file_path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(entry, default=str) + "\n") - - # ------------------------------------------------------------------ - # Prompt logging & token usage helpers - # ------------------------------------------------------------------ - def log_prompt( - self, - *, - input_data: Dict[str, str], - output: Optional[str], - provider: str, - model: str, - config: Dict[str, Any], - status: str, - token_count_input: Optional[int] = None, - token_count_output: Optional[int] = None, - ) -> None: - """ - Store a single prompt interaction with metadata and token counts. - - Each call appends a structured record to the log file so usage metrics - and model behavior can be inspected later. - - Args: - input_data: Serialized prompt inputs sent to the model provider. - output: The raw model output string, if available. - provider: Name of the LLM provider (e.g., OpenAI, Anthropic). - model: Specific model identifier used for the request. - config: Provider-specific configuration details for the call. - status: Execution status for the prompt (e.g., ``"success"`` or - ``"error"``). - token_count_input: Optional token count for the prompt payload. - token_count_output: Optional token count for the model response. - """ - entry = { - "entry_type": "prompt_log", - "datetime": datetime.datetime.utcnow().isoformat(), - "input": input_data, - "output": output, - "provider": provider, - "model": model, - "config": config, - "status": status, - "token_count_input": token_count_input, - "token_count_output": token_count_output, - } - self._append_log_entry(entry) - - def _iter_prompt_logs(self) -> Iterable[Dict[str, Any]]: - for entry in self._load_log_entries(): - if entry.get("entry_type") == "prompt_log": - yield entry - - # ------------------------------------------------------------------ - # Action history logging - # ------------------------------------------------------------------ - def upsert_action_history( - self, - run_id: str, - *, - session_id: str, - parent_id: str | None, - name: str, - action_type: str, - status: str, - inputs: Dict[str, Any] | None, - outputs: Dict[str, Any] | None, - started_at: str | None, - ended_at: str | None, - ) -> None: - """ - Insert or update an action execution history entry. - - The log is keyed by ``run_id``; repeated writes merge new details while - preserving the initial ``startedAt`` value when absent. - - Args: - run_id: Unique identifier for the action execution instance. - session_id: Identifier for the session that triggered the action. - parent_id: Optional run identifier for the parent action in a tree. - name: Human-readable action name. - action_type: Action type label; duplicated into ``type`` for - backward compatibility. - status: Current execution status. - inputs: Serialized action inputs, if available. - outputs: Serialized action outputs, if available. - started_at: ISO timestamp for when execution began. - ended_at: ISO timestamp for when execution completed. - """ - entries = self._load_log_entries() - payload = { - "entry_type": "action_history", - "runId": run_id, - "sessionId": session_id, - "parentId": parent_id, - "name": name, - "action_type": action_type, - "type": action_type, - "status": status, - "inputs": inputs, - "outputs": outputs, - "startedAt": started_at, - "endedAt": ended_at, - } - - found = False - for entry in entries: - if entry.get("entry_type") == "action_history" and entry.get("runId") == run_id: - entry["action_type"] = payload["action_type"] - entry["type"] = payload["type"] - entry.update({k: v for k, v in payload.items() if v is not None or k in {"inputs", "outputs"}}) - if entry.get("startedAt") is None: - entry["startedAt"] = started_at - found = True - break - - if not found: - if payload["startedAt"] is None: - payload["startedAt"] = datetime.datetime.utcnow().isoformat() - entries.append(payload) - - self._write_log_entries(entries) - - # ------------------------------------------------------------------ - # Fast append-only action logging (for parallel execution) - # ------------------------------------------------------------------ - def log_action_start( - self, - run_id: str, - *, - session_id: str | None, - parent_id: str | None, - name: str, - action_type: str, - inputs: Dict[str, Any] | None, - started_at: str, - ) -> None: - """ - Fast O(1) append for action start - no file read/rewrite. - - This method only appends to the log file, avoiding the O(n) read/search/write - pattern of upsert_action_history. Use this for parallel action execution. - - Args: - run_id: Unique identifier for the action execution instance. - session_id: Identifier for the session that triggered the action. - parent_id: Optional run identifier for the parent action. - name: Human-readable action name. - action_type: Action type label. - inputs: Serialized action inputs. - started_at: ISO timestamp for when execution began. - """ - entry = { - "entry_type": "action_history", - "runId": run_id, - "sessionId": session_id, - "parentId": parent_id, - "name": name, - "action_type": action_type, - "type": action_type, - "status": "running", - "inputs": inputs, - "outputs": None, - "startedAt": started_at, - "endedAt": None, - } - self._append_log_entry(entry) - - def log_action_end( - self, - run_id: str, - *, - outputs: Dict[str, Any] | None, - status: str, - ended_at: str, - ) -> None: - """ - Fast O(1) append for action end - separate entry, no file rewrite. - - This method appends a completion record rather than updating the original - start entry. The get_action_history method merges these records. - - Args: - run_id: Unique identifier for the action execution instance. - outputs: Serialized action outputs. - status: Final execution status (success/error). - ended_at: ISO timestamp for when execution completed. - """ - entry = { - "entry_type": "action_end", - "runId": run_id, - "status": status, - "outputs": outputs, - "endedAt": ended_at, - } - self._append_log_entry(entry) - - async def log_action_start_async( - self, - run_id: str, - *, - session_id: str | None, - parent_id: str | None, - name: str, - action_type: str, - inputs: Dict[str, Any] | None, - started_at: str, - ) -> None: - """Async wrapper for log_action_start - runs file I/O in thread pool.""" - await asyncio.to_thread( - self.log_action_start, - run_id, - session_id=session_id, - parent_id=parent_id, - name=name, - action_type=action_type, - inputs=inputs, - started_at=started_at, - ) - - async def log_action_end_async( - self, - run_id: str, - *, - outputs: Dict[str, Any] | None, - status: str, - ended_at: str, - ) -> None: - """Async wrapper for log_action_end - runs file I/O in thread pool.""" - await asyncio.to_thread( - self.log_action_end, - run_id, - outputs=outputs, - status=status, - ended_at=ended_at, - ) - - def _iter_action_history(self) -> Iterable[Dict[str, Any]]: - for entry in self._load_log_entries(): - if entry.get("entry_type") == "action_history": - yield entry - - def find_actions_by_status(self, status: str) -> List[Dict[str, Any]]: - """ - Return all action history entries matching the given status. - - Args: - status: Status value to filter (e.g., ``"current"`` or ``"pending"``). - - Returns: - List of action history dictionaries where ``status`` matches. - """ - return [entry for entry in self._iter_action_history() if entry.get("status") == status] - - def get_action_history(self, limit: int = 10) -> List[Dict[str, Any]]: - """ - Retrieve recent action history entries ordered by start time. - - This method merges action_history (start) entries with action_end entries - to reconstruct complete action records. This supports the append-only - logging pattern used for parallel execution. - - Args: - limit: Maximum number of entries to return, sorted newest-first. - - Returns: - A list of action history dictionaries truncated to ``limit`` - entries. - """ - starts: Dict[str, Dict[str, Any]] = {} - ends: Dict[str, Dict[str, Any]] = {} - - # Collect start and end entries - for entry in self._load_log_entries(): - entry_type = entry.get("entry_type") - run_id = entry.get("runId") - if not run_id: - continue - - if entry_type == "action_history": - # For duplicate starts (shouldn't happen), keep the latest - starts[run_id] = entry - elif entry_type == "action_end": - ends[run_id] = entry - - # Merge start + end into complete records - history: List[Dict[str, Any]] = [] - for run_id, start in starts.items(): - if run_id in ends: - # Merge end data into start entry - end = ends[run_id] - start["status"] = end.get("status", start.get("status")) - start["outputs"] = end.get("outputs", start.get("outputs")) - start["endedAt"] = end.get("endedAt", start.get("endedAt")) - history.append(start) - - history.sort( - key=lambda e: datetime.datetime.fromisoformat(e.get("startedAt") or datetime.datetime.min.isoformat()), - reverse=True, - ) - return history[:limit] - - # ------------------------------------------------------------------ - # Task logging helpers - # ------------------------------------------------------------------ - def log_task(self, task: Task) -> None: - """ - Persist or update a task log entry for tracking execution progress. - - The task is serialized to JSON-compatible primitives and either - appended to the log or merged with an existing entry for the same task - identifier. - - Args: - task: The :class:`~core.task.task.Task` instance to record. - """ - doc = { - "entry_type": "task_log", - "task_id": task.id, - "name": task.name, - "instruction": task.instruction, - "todos": [asdict(todo) for todo in task.todos], - "created_at": task.created_at, - "status": task.status, - "updated_at": datetime.datetime.utcnow().isoformat(), - } - - entries = self._load_log_entries() - for entry in entries: - if entry.get("entry_type") == "task_log" and entry.get("task_id") == task.id: - entry.update(doc) - break - else: - entries.append(doc) - - self._write_log_entries(entries) - - def _iter_task_logs(self) -> Iterable[Dict[str, Any]]: - for entry in self._load_log_entries(): - if entry.get("entry_type") == "task_log": - yield entry - # ------------------------------------------------------------------ # Action definitions (filesystem + Chroma) # ------------------------------------------------------------------ @@ -594,56 +218,3 @@ def _load_task_documents_from_disk(self) -> List[Dict[str, Any]]: } ) return docs - - # ------------------------------------------------------------------ - # Task helpers (for recovery) - # ------------------------------------------------------------------ - def find_current_task_steps(self) -> List[Dict[str, Any]]: - """ - List steps across all tasks that are marked as ``current``. - - Returns: - A list of dictionaries pairing ``task_id`` with the active step - metadata. - """ - results: List[Dict[str, Any]] = [] - for entry in self._iter_task_logs(): - task_id = entry.get("task_id") - for step in entry.get("steps", []): - if step.get("status") == "current": - results.append({"task_id": task_id, "step": step}) - return results - - def update_step_status( - self, - task_id: str, - action_id: str, - status: str, - failure_message: Optional[str] = None, - ) -> None: - """ - Update the status of a task step and persist the change. - - Args: - task_id: Identifier for the task owning the step. - action_id: The step's action identifier used to locate it. - status: New status string to assign to the step. - failure_message: Optional failure detail to attach when updating. - """ - entries = self._load_log_entries() - updated = False - for entry in entries: - if entry.get("entry_type") != "task_log" or entry.get("task_id") != task_id: - continue - for step in entry.get("steps", []): - if step.get("action_id") == action_id: - step["status"] = status - if failure_message is not None: - step["failure_message"] = failure_message - updated = True - break - if updated: - entry["updated_at"] = datetime.datetime.utcnow().isoformat() - break - if updated: - self._write_log_entries(entries) diff --git a/agent_core/core/impl/action/executor.py b/agent_core/core/impl/action/executor.py index 23706385..1498413e 100644 --- a/agent_core/core/impl/action/executor.py +++ b/agent_core/core/impl/action/executor.py @@ -39,6 +39,70 @@ # Default timeout for action execution (100 minutes, GUI mode might need more time) DEFAULT_ACTION_TIMEOUT = 6000 +# Persistent venv for sandboxed actions (reused across calls) +_PERSISTENT_VENV_DIR: Optional[Path] = None +_PERSISTENT_VENV_LOCK = None # Will be initialized lazily to avoid issues with ProcessPoolExecutor + +# Base packages that must be installed in the sandbox venv (empty - venv isolation is the sandbox) +_SANDBOX_BASE_PACKAGES = [] + + +def _get_persistent_venv_dir() -> Path: + """Get the persistent venv directory path.""" + # Store venv in user's home directory under .craftbot + return Path.home() / ".craftbot" / "sandbox_venv" + + +def _ensure_persistent_venv() -> Path: + """ + Ensure the persistent venv exists and return the path to its Python binary. + Creates the venv lazily on first use. Subsequent calls reuse the existing venv. + Ensures base packages (like RestrictedPython) are installed. + """ + global _PERSISTENT_VENV_DIR + + venv_dir = _get_persistent_venv_dir() + python_bin = ( + venv_dir / "Scripts" / "python.exe" + if os.name == "nt" + else venv_dir / "bin" / "python" + ) + + venv_existed = venv_dir.exists() and python_bin.exists() + + if not venv_existed: + # Create parent directory if needed + venv_dir.parent.mkdir(parents=True, exist_ok=True) + + # Create the venv (only happens once) + logger.info(f"[VENV] Creating persistent sandbox venv at {venv_dir}") + venv.EnvBuilder(with_pip=True).create(venv_dir) + logger.info(f"[VENV] Persistent sandbox venv created successfully") + + _PERSISTENT_VENV_DIR = venv_dir + + # Ensure base packages are installed (check even for existing venvs) + # Use a marker file to avoid checking pip on every call + marker_file = venv_dir / ".base_packages_installed" + if not marker_file.exists() and _SANDBOX_BASE_PACKAGES: + logger.info(f"[VENV] Installing base packages: {_SANDBOX_BASE_PACKAGES}") + try: + result = subprocess.run( + [str(python_bin), "-m", "pip", "install", "--quiet"] + _SANDBOX_BASE_PACKAGES, + capture_output=True, + timeout=120 + ) + if result.returncode == 0: + # Create marker file to skip this check on future calls + marker_file.write_text("installed") + logger.info(f"[VENV] Base packages installed successfully") + else: + logger.warning(f"[VENV] pip install returned non-zero: {result.stderr}") + except Exception as e: + logger.warning(f"[VENV] Failed to install base packages: {e}") + + return python_bin + # Optional GUI handler hook - set by agent at startup if GUI mode is needed _gui_execute_hook: Optional[Callable[[str, str, Dict, str], Dict]] = None @@ -248,9 +312,12 @@ def _atomic_action_venv_process( requirements: Optional[List[str]] = None, ) -> dict: """ - Executes an action inside an ephemeral virtual environment. + Executes an action inside a persistent virtual environment. Runs in a SEPARATE PROCESS via ProcessPoolExecutor. + The venv is created once and reused across all calls. Packages installed + via pip persist in the venv, eliminating redundant installations. + stdout/stderr are suppressed at the OS level so that venv creation and other subprocess calls do not corrupt the parent's TUI. """ @@ -261,41 +328,44 @@ def _atomic_action_venv_process( # Suppress worker stdout/stderr to prevent TUI corruption saved_stdout, saved_stderr = _suppress_worker_stdio() - # Sandboxed mode - NOT in a Docker container try: - with tempfile.TemporaryDirectory(prefix="action_venv_") as tmpdir: + # Get or create persistent venv (reused across calls) + python_bin = _ensure_persistent_venv() + + # Install requirements only if not already installed + if requirements: + for pkg in requirements: + pkg = pkg.strip() + if not pkg: + continue + # Check if package is already installed before attempting install + check_result = subprocess.run( + [str(python_bin), "-m", "pip", "show", "--quiet", pkg], + capture_output=True, + timeout=15 + ) + if check_result.returncode == 0: + continue # Already installed, skip + + try: + pip_result = subprocess.run( + [str(python_bin), "-m", "pip", "install", "--quiet", pkg], + capture_output=True, + text=True, + timeout=120 + ) + if pip_result.returncode != 0: + stderr_lower = pip_result.stderr.lower() + if "no matching distribution" not in stderr_lower and "could not find" not in stderr_lower: + print(f"Warning: Could not install '{pkg}': {pip_result.stderr.strip()[:100]}", file=sys.stderr) + except subprocess.TimeoutExpired: + print(f"Warning: Installation timed out for '{pkg}'", file=sys.stderr) + except Exception as e: + print(f"Warning: Error installing '{pkg}': {e}", file=sys.stderr) + + # Write action script to temp file (only the script is temporary, not the venv) + with tempfile.TemporaryDirectory(prefix="action_script_") as tmpdir: tmp = Path(tmpdir) - - # Create virtual environment - venv_dir = tmp / "venv" - venv.EnvBuilder(with_pip=True).create(venv_dir) - - python_bin = ( - venv_dir / "Scripts" / "python.exe" - if os.name == "nt" - else venv_dir / "bin" / "python" - ) - - # Install requirements in the venv - if requirements: - for pkg in requirements: - try: - pip_result = subprocess.run( - [str(python_bin), "-m", "pip", "install", "--quiet", pkg], - capture_output=True, - text=True, - timeout=120 - ) - if pip_result.returncode != 0: - stderr_lower = pip_result.stderr.lower() - if "no matching distribution" not in stderr_lower and "could not find" not in stderr_lower: - print(f"Warning: Could not install '{pkg}': {pip_result.stderr.strip()[:100]}", file=sys.stderr) - except subprocess.TimeoutExpired: - print(f"Warning: Installation timed out for '{pkg}'", file=sys.stderr) - except Exception as e: - print(f"Warning: Error installing '{pkg}': {e}", file=sys.stderr) - - # Write action script action_file = tmp / "action.py" action_file.write_text( f""" diff --git a/agent_core/core/impl/action/manager.py b/agent_core/core/impl/action/manager.py index 75ca3091..46645263 100644 --- a/agent_core/core/impl/action/manager.py +++ b/agent_core/core/impl/action/manager.py @@ -92,6 +92,33 @@ def __init__( self._on_action_end = on_action_end self._get_parent_id = get_parent_id + def _generate_unique_session_id(self) -> str: + """Generate a unique 6-character session ID. + + Creates a short session ID using the first 6 hex characters of a UUID4. + Checks for duplicates against active task IDs from state_manager. + + Returns: + A unique 6-character hex string session ID. + """ + max_attempts = 100 + for _ in range(max_attempts): + candidate = uuid.uuid4().hex[:6] + + # Check against active task IDs from state manager + try: + main_state = self.state_manager.get_main_state() + existing_ids = set(main_state.active_task_ids) if main_state else set() + except Exception: + existing_ids = set() + + if candidate not in existing_ids: + return candidate + + # Fallback to full UUID hex if somehow all short IDs are taken + logger.warning("Could not generate unique 6-char session ID after 100 attempts, using full UUID") + return uuid.uuid4().hex + # ------------------------------------------------------------------ # Public helpers # ------------------------------------------------------------------ @@ -153,17 +180,6 @@ async def execute_action( if not parent_id and self._get_parent_id: parent_id = self._get_parent_id() - # Persist RUNNING status using fast append-only logging - await self.db_interface.log_action_start_async( - run_id=run_id, - session_id=session_id, - parent_id=parent_id, - name=action.name, - action_type=action.action_type, - inputs=input_data, - started_at=started_at, - ) - # Call on_action_start hook if provided if self._on_action_start: try: @@ -303,14 +319,6 @@ async def execute_action( state.get_agent_property("action_count", 0) + 1 ) - # Persist final status using fast append-only logging - await self.db_interface.log_action_end_async( - run_id=run_id, - outputs=outputs, - status=status, - ended_at=ended_at, - ) - # Call on_action_end hook if provided if self._on_action_end: try: @@ -397,7 +405,7 @@ async def execute_single(action: Action, input_data: Dict, action_session_id: st for action, input_data in actions: if action.name == "task_start": # Generate unique session_id for each task_start to prevent overwriting - action_session_id = str(uuid.uuid4()) + action_session_id = self._generate_unique_session_id() logger.info(f"[PARALLEL] Assigning unique session_id {action_session_id} to task_start") else: action_session_id = session_id @@ -429,34 +437,6 @@ async def execute_single(action: Action, input_data: Dict, action_session_id: st # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ - - def _log_action_history( - self, - *, - run_id: str, - action: Action, - inputs: Optional[Dict], - outputs: Optional[Dict], - status: str, - started_at: Optional[str], - ended_at: Optional[str], - parent_id: Optional[str], - session_id: Optional[str], - ) -> None: - """Upsert a single history document keyed by *runId*.""" - self.db_interface.upsert_action_history( - run_id, - session_id=session_id, - parent_id=parent_id, - name=action.name, - action_type=action.action_type, - status=status, - inputs=inputs, - outputs=outputs, - started_at=started_at, - ended_at=ended_at, - ) - def _log_event_stream( self, is_gui_task: bool, @@ -611,19 +591,3 @@ async def run_observe_step(self, action: Action, action_output: Dict) -> Dict[st attempt += 1 return {"success": False, "message": "Observation failed or timed out."} - - # ------------------------------------------------------------------ - # Helper - # ------------------------------------------------------------------ - - def get_action_history(self, limit: int = 10) -> List[Dict[str, Any]]: - """ - Retrieve recent action history entries. - - Args: - limit: Maximum number of history documents to return. - - Returns: - List[Dict[str, Any]]: Collection of run metadata. - """ - return self.db_interface.get_action_history(limit) diff --git a/agent_core/core/impl/action/router.py b/agent_core/core/impl/action/router.py index 1464047c..19da0ec4 100644 --- a/agent_core/core/impl/action/router.py +++ b/agent_core/core/impl/action/router.py @@ -95,6 +95,15 @@ async def select_action( # Base conversation mode actions base_actions = ["send_message", "task_start", "ignore"] + # Integration management actions (always available so the agent can + # help users connect / disconnect external apps via conversation) + integration_actions = [ + "list_available_integrations", + "connect_integration", + "disconnect_integration", + "check_integration_status", + ] + # Dynamically add messaging actions for connected platforms try: from app.external_comms.integration_discovery import ( @@ -103,10 +112,10 @@ async def select_action( ) connected_platforms = get_connected_messaging_platforms() messaging_actions = get_messaging_actions_for_platforms(connected_platforms) - conversation_mode_actions = base_actions + messaging_actions + conversation_mode_actions = base_actions + integration_actions + messaging_actions except Exception as e: logger.debug(f"[ACTION] Could not discover messaging actions: {e}") - conversation_mode_actions = base_actions + conversation_mode_actions = base_actions + integration_actions action_candidates = [] @@ -215,6 +224,7 @@ async def select_action_in_task( # Build the instruction prompt for the LLM task_state = self.context_engine.get_task_state(session_id=session_id) memory_context = self.context_engine.get_memory_context(query, session_id=session_id) + event_stream_content = self.context_engine.get_event_stream(session_id=session_id) static_prompt = SELECT_ACTION_IN_TASK_PROMPT.format( agent_state=self.context_engine.get_agent_state(session_id=session_id), task_state=task_state, @@ -227,7 +237,7 @@ async def select_action_in_task( agent_state=self.context_engine.get_agent_state(session_id=session_id), task_state=task_state, memory_context=memory_context, - event_stream=self.context_engine.get_event_stream(session_id=session_id), + event_stream=event_stream_content, query=query, action_candidates=self._format_candidates(action_candidates), ) @@ -320,6 +330,7 @@ async def select_action_in_simple_task( # Build the instruction prompt task_state = self.context_engine.get_task_state(session_id=session_id) memory_context = self.context_engine.get_memory_context(query, session_id=session_id) + event_stream_content = self.context_engine.get_event_stream(session_id=session_id) static_prompt = SELECT_ACTION_IN_SIMPLE_TASK_PROMPT.format( agent_state=self.context_engine.get_agent_state(session_id=session_id), task_state=task_state, @@ -332,7 +343,7 @@ async def select_action_in_simple_task( agent_state=self.context_engine.get_agent_state(session_id=session_id), task_state=task_state, memory_context=memory_context, - event_stream=self.context_engine.get_event_stream(session_id=session_id), + event_stream=event_stream_content, query=query, action_candidates=self._format_candidates(action_candidates), ) @@ -422,6 +433,7 @@ async def select_action_in_GUI( # Build the instruction prompt for the LLM task_state = self.context_engine.get_task_state(session_id=session_id) memory_context = self.context_engine.get_memory_context(query, session_id=session_id) + event_stream_content = self.context_engine.get_event_stream(session_id=session_id) static_prompt = SELECT_ACTION_IN_GUI_PROMPT.format( agent_state=self.context_engine.get_agent_state(session_id=session_id), task_state=task_state, @@ -432,7 +444,7 @@ async def select_action_in_GUI( full_prompt = SELECT_ACTION_IN_GUI_PROMPT.format( agent_state=self.context_engine.get_agent_state(session_id=session_id), task_state=task_state, - event_stream=self.context_engine.get_event_stream(session_id=session_id), + event_stream=event_stream_content, memory_context=memory_context, gui_action_space=GUI_ACTION_SPACE_PROMPT, ) diff --git a/agent_core/core/impl/config/watcher.py b/agent_core/core/impl/config/watcher.py index 86a6eb88..afd57e13 100644 --- a/agent_core/core/impl/config/watcher.py +++ b/agent_core/core/impl/config/watcher.py @@ -233,25 +233,20 @@ def _trigger_reload(self, file_path: Path) -> None: # Check if callback is async if asyncio.iscoroutinefunction(callback): if self._event_loop and self._event_loop.is_running(): - # Schedule in the event loop - asyncio.run_coroutine_threadsafe(callback(), self._event_loop) + # Schedule in the event loop (non-blocking) + future = asyncio.run_coroutine_threadsafe(callback(), self._event_loop) + future.add_done_callback(lambda f: f.exception()) # Suppress unhandled exception warning else: - # Create new event loop for this thread asyncio.run(callback()) else: - # Sync callback callback() # Update last modified time if config.path.exists(): config.last_modified = config.path.stat().st_mtime - logger.info(f"[CONFIG_WATCHER] Reload complete for {file_path.name}") - except Exception as e: - logger.error(f"[CONFIG_WATCHER] Reload failed for {file_path.name}: {e}") - import traceback - logger.debug(traceback.format_exc()) + logger.warning(f"[CONFIG_WATCHER] Reload error for {file_path.name}: {e}") # Global singleton instance diff --git a/agent_core/core/impl/context/engine.py b/agent_core/core/impl/context/engine.py index 53231b92..fcd45cf1 100644 --- a/agent_core/core/impl/context/engine.py +++ b/agent_core/core/impl/context/engine.py @@ -24,6 +24,7 @@ AGENT_FILE_SYSTEM_CONTEXT_PROMPT, POLICY_PROMPT, USER_PROFILE_PROMPT, + LANGUAGE_INSTRUCTION, ) from agent_core.core.state import get_state, get_session_or_none from agent_core.core.task import Task @@ -224,6 +225,14 @@ def create_system_user_profile(self) -> str: return "" + def create_system_language_instruction(self) -> str: + """Create a system message block with language instruction. + + Returns the language instruction that tells the agent to use + the user's preferred language as specified in USER.md. + """ + return LANGUAGE_INSTRUCTION + def create_system_base_instruction(self) -> str: """Create a system message of instruction.""" return "Please assist the user using the context given in the conversation or event stream." @@ -529,6 +538,74 @@ def get_user_info(self) -> str: """Get current user info for user prompts (WCA-specific via hook).""" return self._get_user_info() + def _build_memory_query(self, query: Optional[str], session_id: Optional[str]) -> Optional[str]: + """Build a semantic query for memory retrieval. + + Combines task instruction with recent conversation messages (both user + and agent) to provide better context for memory search. + + Args: + query: Optional explicit query string. + session_id: Optional session ID for session-specific state lookup. + + Returns: + A query string suitable for semantic memory search, or None if no context. + """ + # Get task instruction as the base query + session = get_session_or_none(session_id) + if session and session.current_task: + task_instruction = session.current_task.instruction + else: + current_task = get_state().current_task + task_instruction = current_task.instruction if current_task else None + + if not task_instruction: + # Fall back to explicit query if no task + return query if query else None + + # Get recent conversation messages for additional context + recent_context = self._get_recent_conversation_for_memory(session_id, limit=5) + + if recent_context: + return f"{task_instruction}\n\nRecent conversation:\n{recent_context}" + else: + return task_instruction + + def _get_recent_conversation_for_memory(self, session_id: Optional[str], limit: int = 5) -> str: + """Get recent conversation messages for memory query context. + + Args: + session_id: Optional session ID for session-specific event stream. + limit: Maximum number of messages to include. + + Returns: + Formatted string of recent user and agent messages. + """ + try: + event_stream_manager = self.state_manager.event_stream_manager + if not event_stream_manager: + return "" + + # Get messages from conversation history (includes both user and agent) + recent_messages = event_stream_manager.get_recent_conversation_messages(limit) + if not recent_messages: + return "" + + # Format messages simply for semantic search + lines = [] + for event in recent_messages: + # Simplify the kind label for the query + if "user message" in event.kind: + lines.append(f"User: {event.message}") + elif "agent message" in event.kind: + lines.append(f"Agent: {event.message}") + + return "\n".join(lines) + + except Exception as e: + logger.warning(f"[MEMORY] Failed to get recent conversation: {e}") + return "" + def get_memory_context( self, query: Optional[str] = None, top_k: int = 5, session_id: Optional[str] = None ) -> str: @@ -536,7 +613,7 @@ def get_memory_context( Args: query: Optional query string for memory retrieval. If not provided, - uses current task instruction. + uses current task instruction combined with recent conversation. top_k: Number of top memories to retrieve. session_id: Optional session ID for session-specific state lookup. """ @@ -547,21 +624,14 @@ def get_memory_context( if not _is_memory_enabled(): return "" - if not query: - # Try session-specific state first - session = get_session_or_none(session_id) - if session and session.current_task: - current_task = session.current_task - else: - current_task = get_state().current_task - - if current_task: - query = current_task.instruction - else: - return "" + # Build semantic query from task instruction + recent conversation + # This provides better context than using the raw trigger description + memory_query = self._build_memory_query(query, session_id) + if not memory_query: + return "" try: - pointers = self._memory_manager.retrieve(query, top_k=top_k, min_relevance=0.3) + pointers = self._memory_manager.retrieve(memory_query, top_k=top_k, min_relevance=0.3) if not pointers: return "" @@ -613,7 +683,8 @@ def make_prompt( "role_info": True, "agent_info": True, "user_profile": True, - "policy": False, + "language_instruction": True, + "policy": True, "environment": True, "file_system": True, "base_instruction": True, @@ -629,6 +700,7 @@ def make_prompt( system_sections = [ ("agent_info", self.create_system_agent_info), ("user_profile", self.create_system_user_profile), + ("language_instruction", self.create_system_language_instruction), ("policy", self.create_system_policy), ("role_info", self.create_system_role_info), ("environment", self.create_system_environmental_context), diff --git a/agent_core/core/impl/event_stream/event_stream.py b/agent_core/core/impl/event_stream/event_stream.py index 3754765b..323a242d 100644 --- a/agent_core/core/impl/event_stream/event_stream.py +++ b/agent_core/core/impl/event_stream/event_stream.py @@ -15,7 +15,6 @@ """ from __future__ import annotations -import asyncio from datetime import datetime, timezone, timedelta import re import time @@ -92,8 +91,8 @@ def __init__( self, *, llm: LLMInterfaceProtocol, - summarize_at_tokens: int = 8000, - tail_keep_after_summarize_tokens: int = 4000, + summarize_at_tokens: int = 30000, + tail_keep_after_summarize_tokens: int = 10000, temp_dir: Path | None = None, ) -> None: self.head_summary: Optional[str] = None @@ -112,7 +111,6 @@ def __init__( ) self.tail_keep_after_summarize_tokens = summarize_at_tokens - MINIMUM_BUFFER_TOKENS_BEFORE_NEXT_SUMMARIZATION - self._summarize_task: asyncio.Task | None = None self._lock = threading.RLock() self._total_tokens: int = 0 @@ -157,10 +155,13 @@ def log( ev = Event(message=msg, kind=kind.strip(), severity=severity, display_message=display) rec = EventRecord(event=ev) - self.tail_events.append(rec) - self._total_tokens += get_cached_token_count(rec) - self.summarize_if_needed() - return len(self.tail_events) - 1 + with self._lock: + self.tail_events.append(rec) + self._total_tokens += get_cached_token_count(rec) + # Summarization runs inside the lock - blocks other log() calls + # until summarization completes + self.summarize_if_needed() + return len(self.tail_events) - 1 # Convenience wrappers for common event families (optional use) def log_action_start(self, name: str) -> int: @@ -206,32 +207,14 @@ def summarize_if_needed(self) -> None: """ Trigger summarization when the tail token count exceeds the configured threshold. - Uses asyncio.create_task to schedule summarize_by_LLM() without requiring - callers of log() to be async/await. + This is a SYNCHRONOUS blocking call - if summarization is needed, it runs + immediately and waits for completion before returning. """ if self._total_tokens < self.summarize_at_tokens: return - if self._summarize_task is not None and not self._summarize_task.done(): - return - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - logger.warning("[EventStream] No running event loop; cannot schedule summarization.") - return - logger.debug(f"[EventStream] Triggering summarization: {self._total_tokens} tokens >= {self.summarize_at_tokens} threshold") - self._summarize_task = loop.create_task(self.summarize_by_LLM(), name="eventstream_summarize") - self._summarize_task.add_done_callback(self._on_summarize_done) - - def _on_summarize_done(self, task: asyncio.Task) -> None: - try: - task.result() - except asyncio.CancelledError: - return - except Exception: - logger.exception("[EventStream] summarize_by_LLM task crashed unexpectedly") + self.summarize_by_LLM() def _find_token_cutoff(self, events: List[EventRecord], keep_tokens: int) -> int: """ @@ -263,43 +246,44 @@ def _find_token_cutoff(self, events: List[EventRecord], keep_tokens: int) -> int ) return cutoff - async def summarize_by_LLM(self) -> None: + def summarize_by_LLM(self) -> None: """ Summarize the oldest tail events using the language model. - This version is concurrency-safe with synchronous log() calls: - - Snapshot the chunk under a lock - - Release lock while awaiting the LLM - - Re-acquire lock to apply summary + prune using the *current* tail - so events appended during the await are not lost. + This is a SYNCHRONOUS blocking call that holds the lock for the entire + duration, including the LLM call. This ensures no events can be added + while summarization is in progress. + + Called from log() which already holds the lock (RLock allows reentry). """ - with self._lock: - if not self.tail_events: - return + if not self.tail_events: + return - # Find cutoff based on tokens to keep - cutoff = self._find_token_cutoff(self.tail_events, self.tail_keep_after_summarize_tokens) + # Find cutoff based on tokens to keep + cutoff = self._find_token_cutoff(self.tail_events, self.tail_keep_after_summarize_tokens) - if cutoff <= 0: - # Nothing old enough to summarize - return + if cutoff <= 0: + # Nothing old enough to summarize + return - chunk = list(self.tail_events[:cutoff]) - first_ts = chunk[0].ts if chunk else None - last_ts = chunk[-1].ts if chunk else None - window = "" - if first_ts and last_ts: - window = f"{first_ts.isoformat()} to {last_ts.isoformat()}" + chunk = list(self.tail_events[:cutoff]) + first_ts = chunk[0].ts if chunk else None + last_ts = chunk[-1].ts if chunk else None + window = "" + if first_ts and last_ts: + window = f"{first_ts.isoformat()} to {last_ts.isoformat()}" - compact_lines = "\n".join(r.compact_line() for r in chunk) - previous_summary = self.head_summary or "(none)" + compact_lines = "\n".join(r.compact_line() for r in chunk) + previous_summary = self.head_summary or "(none)" - prompt = EVENT_STREAM_SUMMARIZATION_PROMPT.format(window=window, previous_summary=previous_summary, compact_lines=compact_lines) + prompt = EVENT_STREAM_SUMMARIZATION_PROMPT.format( + window=window, previous_summary=previous_summary, compact_lines=compact_lines + ) try: - llm_output = await self.llm.generate_response_async(user_prompt=prompt) + logger.info(f"[EventStream] Running synchronous summarization ({self._total_tokens} tokens)") + llm_output = self.llm.generate_response(user_prompt=prompt) new_summary = (llm_output or "").strip() - # timestamp can be added here. For example: (from 'start time' to 'end time') logger.debug(f"[EVENT STREAM SUMMARIZATION] llm_output_len={len(llm_output or '')}") @@ -307,25 +291,19 @@ async def summarize_by_LLM(self) -> None: logger.warning("[EVENT STREAM SUMMARIZATION] LLM returned empty summary; not updating.") return - # Apply + prune under lock - with self._lock: - self.head_summary = new_summary - # Calculate tokens being removed (using cached values) - removed_tokens = sum(get_cached_token_count(r) for r in self.tail_events[:cutoff]) - self._total_tokens -= removed_tokens - if cutoff >= len(self.tail_events): - self.tail_events = [] - else: - self.tail_events = self.tail_events[cutoff:] - - # Reset all session sync points - event indices are now invalid - # Session caches will be recreated on next access - self._session_sync_points.clear() - logger.info("[EventStream] Cleared all session sync points after summarization") + # Apply summary and prune events + self.head_summary = new_summary + # Calculate tokens being removed from the snapshotted chunk + removed_tokens = sum(get_cached_token_count(r) for r in chunk) + self._total_tokens -= removed_tokens + self.tail_events = self.tail_events[cutoff:] + + # Reset all session sync points - event indices are now invalid + self._session_sync_points.clear() + logger.info(f"[EventStream] Summarization complete. Tokens: {self._total_tokens}") except Exception: logger.exception("[EventStream] LLM summarization failed. Keeping all events without summarization.") - return # ───────────────────── utilities ───────────────────── diff --git a/agent_core/core/impl/llm/__init__.py b/agent_core/core/impl/llm/__init__.py index 39948917..fb963ea8 100644 --- a/agent_core/core/impl/llm/__init__.py +++ b/agent_core/core/impl/llm/__init__.py @@ -11,6 +11,7 @@ from agent_core.core.impl.llm.interface import LLMInterface from agent_core.core.impl.llm.types import LLMCallType +from agent_core.core.impl.llm.errors import LLMConsecutiveFailureError # Cache management components from agent_core.core.impl.llm.cache import ( @@ -30,6 +31,8 @@ "LLMInterface", # Types "LLMCallType", + # Errors + "LLMConsecutiveFailureError", # Cache Config "CacheConfig", "get_cache_config", diff --git a/agent_core/core/impl/llm/errors.py b/agent_core/core/impl/llm/errors.py index 83ea1a4c..e310f686 100644 --- a/agent_core/core/impl/llm/errors.py +++ b/agent_core/core/impl/llm/errors.py @@ -29,6 +29,26 @@ # User-friendly messages MSG_AUTH = "Unable to connect to AI service. Please check your API key in Settings." +MSG_CONSECUTIVE_FAILURE = ( + "LLM calls have failed {count} consecutive times. " + "Task aborted to prevent infinite retries. Please check your LLM configuration." +) + + +class LLMConsecutiveFailureError(Exception): + """Raised when LLM calls fail too many times consecutively. + + This exception signals that the task should be aborted to prevent + infinite retry loops that flood logs and waste resources. + """ + + def __init__(self, failure_count: int, last_error: Optional[Exception] = None): + self.failure_count = failure_count + self.last_error = last_error + message = MSG_CONSECUTIVE_FAILURE.format(count=failure_count) + if last_error: + message += f" Last error: {last_error}" + super().__init__(message) MSG_MODEL = "The selected AI model is not available. Please check your model settings." MSG_CONFIG = "AI service configuration error. The selected model may not support required features." MSG_RATE_LIMIT = "AI service is rate-limited. Please wait a moment and try again." diff --git a/agent_core/core/impl/llm/interface.py b/agent_core/core/impl/llm/interface.py index a4f2525f..1b67209a 100644 --- a/agent_core/core/impl/llm/interface.py +++ b/agent_core/core/impl/llm/interface.py @@ -29,6 +29,7 @@ get_cache_config, get_cache_metrics, ) +from agent_core.core.impl.llm.errors import LLMConsecutiveFailureError from agent_core.core.hooks import ( GetTokenCountHook, SetTokenCountHook, @@ -126,6 +127,10 @@ def __init__( self._report_usage = report_usage self._log_to_db = log_to_db + # Consecutive failure tracking to prevent infinite retry loops + self._consecutive_failures = 0 + self._max_consecutive_failures = 5 + # Defer imports to avoid circular dependency from app.models.factory import ModelFactory from app.models.types import InterfaceType @@ -152,6 +157,7 @@ def __init__( # Initialize BytePlus-specific attributes self._byteplus_cache_manager: Optional[BytePlusCacheManager] = None + self.byteplus_base_url: Optional[str] = None # Store system prompts for lazy session creation (instance variable) self._session_system_prompts: Dict[str, str] = {} @@ -328,49 +334,82 @@ def _generate_response_sync( if user_prompt is None: raise ValueError("`user_prompt` cannot be None.") + # Check if we've hit the consecutive failure threshold + if self._consecutive_failures >= self._max_consecutive_failures: + logger.critical( + f"[LLM ABORT] Consecutive failure threshold reached " + f"({self._consecutive_failures}/{self._max_consecutive_failures}). " + f"Aborting to prevent infinite retries." + ) + raise LLMConsecutiveFailureError(self._consecutive_failures) + if log_response: logger.info(f"[LLM SEND] system={system_prompt} | user={user_prompt}") - if self.provider == "openai": - response = self._generate_openai(system_prompt, user_prompt) - elif self.provider == "remote": - response = self._generate_ollama(system_prompt, user_prompt) - elif self.provider == "gemini": - response = self._generate_gemini(system_prompt, user_prompt) - elif self.provider == "byteplus": - response = self._generate_byteplus(system_prompt, user_prompt) - elif self.provider == "anthropic": - response = self._generate_anthropic(system_prompt, user_prompt) - else: # pragma: no cover - raise RuntimeError(f"Unknown provider {self.provider!r}") - - content = response.get("content", "").strip() - - # Check if response is empty and provide diagnostics - if not content: - error_msg = response.get("error", "") - if error_msg: - error_detail = f"LLM provider returned error: {error_msg}" - else: - error_detail = ( - f"LLM returned empty response. " - f"Provider: {self.provider}, Model: {self.model}. " - f"This may indicate: API authentication failure, invalid API key, rate limiting, " - f"connection timeout, or LLM service unavailability. " - f"Check your credentials and API status." + try: + if self.provider in ("openai", "minimax", "deepseek", "moonshot"): + response = self._generate_openai(system_prompt, user_prompt) + elif self.provider == "remote": + response = self._generate_ollama(system_prompt, user_prompt) + elif self.provider == "gemini": + response = self._generate_gemini(system_prompt, user_prompt) + elif self.provider == "byteplus": + response = self._generate_byteplus(system_prompt, user_prompt) + elif self.provider == "anthropic": + response = self._generate_anthropic(system_prompt, user_prompt) + else: # pragma: no cover + raise RuntimeError(f"Unknown provider {self.provider!r}") + + content = response.get("content", "").strip() + + # Check if response is empty and provide diagnostics + if not content: + error_msg = response.get("error", "") + if error_msg: + error_detail = f"LLM provider returned error: {error_msg}" + else: + error_detail = ( + f"LLM returned empty response. " + f"Provider: {self.provider}, Model: {self.model}. " + f"This may indicate: API authentication failure, invalid API key, rate limiting, " + f"connection timeout, or LLM service unavailability. " + f"Check your credentials and API status." + ) + logger.error(f"[LLM ERROR] {error_detail}") + # Track consecutive failure + self._consecutive_failures += 1 + logger.warning( + f"[LLM CONSECUTIVE FAILURE] Count: {self._consecutive_failures}/{self._max_consecutive_failures}" ) - logger.error(f"[LLM ERROR] {error_detail}") - raise RuntimeError(error_detail) - - cleaned = re.sub(self._CODE_BLOCK_RE, "", content) + if self._consecutive_failures >= self._max_consecutive_failures: + raise LLMConsecutiveFailureError(self._consecutive_failures) + raise RuntimeError(error_detail) - # Update token count via hook - current_count = self._get_token_count() - self._set_token_count(current_count + response.get("tokens_used", 0)) + # Success - reset consecutive failure counter + self._consecutive_failures = 0 - if log_response: - logger.info(f"[LLM RECV] {cleaned}") - return cleaned + cleaned = re.sub(self._CODE_BLOCK_RE, "", content) + + # Update token count via hook + current_count = self._get_token_count() + self._set_token_count(current_count + response.get("tokens_used", 0)) + + if log_response: + logger.info(f"[LLM RECV] {cleaned}") + return cleaned + + except LLMConsecutiveFailureError: + # Re-raise consecutive failure errors without incrementing counter + raise + except Exception as e: + # Track consecutive failure for any other exception + self._consecutive_failures += 1 + logger.warning( + f"[LLM CONSECUTIVE FAILURE] Count: {self._consecutive_failures}/{self._max_consecutive_failures} | Error: {e}" + ) + if self._consecutive_failures >= self._max_consecutive_failures: + raise LLMConsecutiveFailureError(self._consecutive_failures, last_error=e) from e + raise @profile("llm_generate_response", OperationCategory.LLM) def generate_response( @@ -397,6 +436,24 @@ async def generate_response_async( log_response, ) + def reset_failure_counter(self) -> None: + """Reset the consecutive failure counter. + + Call this when starting a new task or when the user manually + chooses to retry after fixing configuration issues. + """ + if self._consecutive_failures > 0: + logger.info( + f"[LLM] Resetting consecutive failure counter " + f"(was {self._consecutive_failures})" + ) + self._consecutive_failures = 0 + + @property + def consecutive_failures(self) -> int: + """Get the current consecutive failure count.""" + return self._consecutive_failures + # ─────────────────── Session/Explicit Cache Methods ─────────────────── def create_session_cache( @@ -425,7 +482,7 @@ def create_session_cache( supports_caching = ( (self.provider == "byteplus" and self._byteplus_cache_manager) or (self.provider == "gemini" and self._gemini_cache_manager) or - (self.provider == "openai" and self.client) or # OpenAI uses automatic caching with prompt_cache_key + (self.provider in ("openai", "deepseek") and self.client) or # OpenAI/DeepSeek use automatic caching with prompt_cache_key (self.provider == "anthropic" and self._anthropic_client) # Anthropic uses ephemeral caching with extended TTL ) @@ -522,7 +579,7 @@ def has_session_cache(self, task_id: str, call_type: str) -> bool: return True if self.provider == "gemini" and self._gemini_cache_manager: return True - if self.provider == "openai" and self.client: + if self.provider in ("openai", "deepseek") and self.client: return True if self.provider == "anthropic" and self._anthropic_client: return True @@ -604,8 +661,8 @@ def _generate_response_with_session_sync( logger.info(f"[LLM RECV] {cleaned}") return cleaned - # Handle OpenAI with call_type-based cache routing - if self.provider == "openai": + # Handle OpenAI/DeepSeek with call_type-based cache routing + if self.provider in ("openai", "deepseek"): # Get stored system prompt or use provided one session_key = f"{task_id}:{call_type}" stored_system_prompt = self._session_system_prompts.get(session_key) diff --git a/agent_core/core/impl/mcp/client.py b/agent_core/core/impl/mcp/client.py index 1831e194..c580c7cf 100644 --- a/agent_core/core/impl/mcp/client.py +++ b/agent_core/core/impl/mcp/client.py @@ -249,7 +249,19 @@ async def call_tool( "message": f"MCP server '{server_name}' connection lost", } - return await server.call_tool(tool_name, arguments) + result = await server.call_tool(tool_name, arguments) + + # Record MCP tool call for metrics (only if not an error) + if result.get("status") != "error": + try: + from app.ui_layer.metrics.collector import MetricsCollector + collector = MetricsCollector.get_instance() + if collector: + collector.record_mcp_tool_call(tool_name, server_name) + except Exception: + pass # Don't fail tool execution if metrics recording fails + + return result async def refresh_tools(self, server_name: Optional[str] = None) -> None: """ diff --git a/agent_core/core/impl/mcp/server.py b/agent_core/core/impl/mcp/server.py index fe8c9f6f..42ac7fea 100644 --- a/agent_core/core/impl/mcp/server.py +++ b/agent_core/core/impl/mcp/server.py @@ -156,6 +156,7 @@ async def connect(self) -> bool: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=full_env, + limit=10 * 1024 * 1024, # 10MB limit for large MCP responses (e.g., screenshots) ) else: self._process = await asyncio.create_subprocess_exec( @@ -165,6 +166,7 @@ async def connect(self) -> bool: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=full_env, + limit=10 * 1024 * 1024, # 10MB limit for large MCP responses (e.g., screenshots) ) except FileNotFoundError as e: logger.error(f"[StdioTransport] Command not found: '{command}'. Make sure it is installed and in PATH. Error: {e}") diff --git a/agent_core/core/impl/settings/manager.py b/agent_core/core/impl/settings/manager.py index c184f985..a4774711 100644 --- a/agent_core/core/impl/settings/manager.py +++ b/agent_core/core/impl/settings/manager.py @@ -38,7 +38,10 @@ "openai": "", "anthropic": "", "google": "", - "byteplus": "" + "byteplus": "", + "minimax": "", + "deepseek": "", + "moonshot": "" }, "endpoints": { "remote_model_url": "", diff --git a/agent_core/core/impl/skill/manager.py b/agent_core/core/impl/skill/manager.py index 8aa58bed..d5fc9d4d 100644 --- a/agent_core/core/impl/skill/manager.py +++ b/agent_core/core/impl/skill/manager.py @@ -276,6 +276,8 @@ def enable_skill(self, name: str) -> bool: if self._config: if name in self._config.disabled_skills: self._config.disabled_skills.remove(name) + if self._config.enabled_skills and name not in self._config.enabled_skills: + self._config.enabled_skills.append(name) self._save_config() logger.info(f"[SKILLS] Enabled skill: {name}") @@ -300,6 +302,8 @@ def disable_skill(self, name: str) -> bool: if self._config: if name not in self._config.disabled_skills: self._config.disabled_skills.append(name) + if name in self._config.enabled_skills: + self._config.enabled_skills.remove(name) self._save_config() logger.info(f"[SKILLS] Disabled skill: {name}") diff --git a/agent_core/core/impl/task/manager.py b/agent_core/core/impl/task/manager.py index f6dd99a1..89156266 100644 --- a/agent_core/core/impl/task/manager.py +++ b/agent_core/core/impl/task/manager.py @@ -271,7 +271,6 @@ def create_task( self.tasks[task_id] = task self._current_session_id = task_id # CraftBot compatibility - self.db_interface.log_task(task) self._sync_state_manager(task) # Notify state manager for two-tier state tracking @@ -400,7 +399,6 @@ def _clean_content(s: str) -> str: transitions.append((item, "pending", "in_progress")) self.active.todos = new_todos - self.db_interface.log_task(self.active) self._sync_state_manager(self.active) # Report transitions via hook if provided (WCA) @@ -585,7 +583,6 @@ async def _end_task( task.final_summary = summary task.errors = errors or [] - self.db_interface.log_task(task) self._sync_state_manager(task) self.event_stream_manager.log( diff --git a/agent_core/core/impl/trigger/queue.py b/agent_core/core/impl/trigger/queue.py index 09bd0ffe..509c8f44 100644 --- a/agent_core/core/impl/trigger/queue.py +++ b/agent_core/core/impl/trigger/queue.py @@ -465,9 +465,8 @@ async def fire( if t.session_id == session_id: t.fire_at = time.time() if message: - t.next_action_description += ( - f"\n\n[NEW USER MESSAGE]: {message}" - ) + # Store in payload instead of polluting the description + t.payload["pending_user_message"] = message if platform: t.payload["pending_platform"] = platform found = True @@ -481,9 +480,8 @@ async def fire( if session_id in self._active: t = self._active[session_id] if message: - t.next_action_description += ( - f"\n\n[NEW USER MESSAGE]: {message}" - ) + # Store in payload instead of polluting the description + t.payload["pending_user_message"] = message if platform: t.payload["pending_platform"] = platform logger.debug(f"[FIRE] Attached message to active trigger for session {session_id}") @@ -528,9 +526,9 @@ def pop_pending_user_message(self, session_id: str) -> tuple[str | None, str | N """ Extract and remove any pending user message from an active trigger. - When fire() attaches a message to an active trigger via - '[NEW USER MESSAGE]: ...', this method extracts that message - so it can be carried forward to the next trigger. + When fire() attaches a message to an active trigger's payload, + this method extracts that message so it can be carried forward + to the next trigger. Args: session_id: The session to check for pending messages. @@ -542,23 +540,14 @@ def pop_pending_user_message(self, session_id: str) -> tuple[str | None, str | N return None, None trigger = self._active[session_id] - marker = "\n\n[NEW USER MESSAGE]:" - desc = trigger.next_action_description - - if marker not in desc: - return None, None - - # Extract the message - idx = desc.index(marker) - message = desc[idx + len(marker):].strip() - # Extract and remove the platform from payload + # Extract and remove the message from payload + message = trigger.payload.pop("pending_user_message", None) platform = trigger.payload.pop("pending_platform", None) - # Remove the message from the trigger to avoid duplication - trigger.next_action_description = desc[:idx] + if message: + logger.debug(f"[TRIGGER] Extracted pending user message for session {session_id}: {message[:50]}...") - logger.debug(f"[TRIGGER] Extracted pending user message for session {session_id}: {message[:50]}...") return message, platform # ================================================================= diff --git a/agent_core/core/impl/vlm/interface.py b/agent_core/core/impl/vlm/interface.py index 657da07f..e46d0ac3 100644 --- a/agent_core/core/impl/vlm/interface.py +++ b/agent_core/core/impl/vlm/interface.py @@ -227,7 +227,7 @@ def describe_image_bytes( if log_response: logger.info(f"[LLM SEND] system={system_prompt} | user={user_prompt}") - if self.provider == "openai": + if self.provider in ("openai", "minimax", "deepseek", "moonshot"): response = self._openai_describe_bytes(image_bytes, system_prompt, user_prompt) elif self.provider == "remote": response = self._ollama_describe_bytes(image_bytes, system_prompt, user_prompt) diff --git a/agent_core/core/models/connection_tester.py b/agent_core/core/models/connection_tester.py index 87d25fd5..a1846bc4 100644 --- a/agent_core/core/models/connection_tester.py +++ b/agent_core/core/models/connection_tester.py @@ -51,6 +51,9 @@ def test_provider_connection( elif provider == "remote": url = base_url or cfg.default_base_url return _test_remote(url, timeout) + elif provider in ("minimax", "deepseek", "moonshot"): + url = cfg.default_base_url + return _test_openai_compat(provider, api_key, url, timeout) else: return { "success": False, @@ -348,3 +351,37 @@ def _test_remote(base_url: Optional[str], timeout: float) -> Dict[str, Any]: "provider": "remote", "error": f"Could not connect to {url}: {str(e)}", } + + +def _test_openai_compat( + provider: str, api_key: Optional[str], base_url: str, timeout: float +) -> Dict[str, Any]: + """Test an OpenAI-compatible API (MiniMax, DeepSeek, Moonshot).""" + names = {"minimax": "MiniMax", "deepseek": "DeepSeek", "moonshot": "Moonshot"} + display = names.get(provider, provider) + + if not api_key: + return { + "success": False, + "message": f"API key is required for {display}", + "provider": provider, + "error": "Missing API key", + } + + try: + with httpx.Client(timeout=timeout) as client: + response = client.get( + f"{base_url.rstrip('/')}/models", + headers={"Authorization": f"Bearer {api_key}"}, + ) + + if response.status_code == 200: + return {"success": True, "message": f"Successfully connected to {display} API", "provider": provider} + elif response.status_code == 401: + return {"success": False, "message": "Invalid API key", "provider": provider, "error": "Authentication failed - check your API key"} + else: + return {"success": False, "message": f"API returned status {response.status_code}", "provider": provider, "error": response.text[:200] if response.text else "Unknown error"} + except httpx.TimeoutException: + return {"success": False, "message": "Connection timed out", "provider": provider, "error": "Request timed out - check your network connection"} + except httpx.RequestError as e: + return {"success": False, "message": "Network error", "provider": provider, "error": str(e)} diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index d83528bb..ee7bf931 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -38,6 +38,9 @@ def create( Returns: Dictionary with provider context including client instances """ + # OpenAI-compatible providers that use OpenAI client with a custom base_url + _OPENAI_COMPAT = {"minimax", "deepseek", "moonshot"} + if provider not in PROVIDER_CONFIG: raise ValueError(f"Unsupported provider: {provider}") @@ -144,4 +147,21 @@ def create( "initialized": True, } + if provider in _OPENAI_COMPAT: + if not api_key: + if deferred: + return empty_context + raise ValueError(f"API key required for {provider}") + + return { + "provider": provider, + "model": model, + "client": OpenAI(api_key=api_key, base_url=resolved_base_url), + "gemini_client": None, + "remote_url": None, + "byteplus": None, + "anthropic_client": None, + "initialized": True, + } + raise RuntimeError("Unreachable") diff --git a/agent_core/core/models/model_registry.py b/agent_core/core/models/model_registry.py index d9f2270a..16fd279a 100644 --- a/agent_core/core/models/model_registry.py +++ b/agent_core/core/models/model_registry.py @@ -29,4 +29,19 @@ InterfaceType.VLM: "llava-v1.6", InterfaceType.EMBEDDING: "nomic-embed-text", }, + "minimax": { + InterfaceType.LLM: "MiniMax-Text-01", + InterfaceType.VLM: None, + InterfaceType.EMBEDDING: None, + }, + "deepseek": { + InterfaceType.LLM: "deepseek-chat", + InterfaceType.VLM: "deepseek-chat", + InterfaceType.EMBEDDING: None, + }, + "moonshot": { + InterfaceType.LLM: "moonshot-v1-8k", + InterfaceType.VLM: None, + InterfaceType.EMBEDDING: None, + }, } diff --git a/agent_core/core/models/provider_config.py b/agent_core/core/models/provider_config.py index 3a13ff4e..bc6357f3 100644 --- a/agent_core/core/models/provider_config.py +++ b/agent_core/core/models/provider_config.py @@ -25,4 +25,16 @@ class ProviderConfig: base_url_env="REMOTE_MODEL_URL", default_base_url="http://localhost:11434", ), + "minimax": ProviderConfig( + api_key_env="MINIMAX_API_KEY", + default_base_url="https://api.minimax.chat/v1", + ), + "deepseek": ProviderConfig( + api_key_env="DEEPSEEK_API_KEY", + default_base_url="https://api.deepseek.com", + ), + "moonshot": ProviderConfig( + api_key_env="MOONSHOT_API_KEY", + default_base_url="https://api.moonshot.cn/v1", + ), } diff --git a/agent_core/core/prompts/__init__.py b/agent_core/core/prompts/__init__.py index eb5314b8..6f7dfb64 100644 --- a/agent_core/core/prompts/__init__.py +++ b/agent_core/core/prompts/__init__.py @@ -76,6 +76,7 @@ USER_PROFILE_PROMPT, ENVIRONMENTAL_CONTEXT_PROMPT, AGENT_FILE_SYSTEM_CONTEXT_PROMPT, + LANGUAGE_INSTRUCTION, ) # Routing prompts @@ -120,6 +121,7 @@ "USER_PROFILE_PROMPT", "ENVIRONMENTAL_CONTEXT_PROMPT", "AGENT_FILE_SYSTEM_CONTEXT_PROMPT", + "LANGUAGE_INSTRUCTION", # Routing prompts "ROUTE_TO_SESSION_PROMPT", # GUI prompts diff --git a/agent_core/core/prompts/action.py b/agent_core/core/prompts/action.py index 60b7329c..e53b7952 100644 --- a/agent_core/core/prompts/action.py +++ b/agent_core/core/prompts/action.py @@ -51,9 +51,9 @@ - If platform is WhatsApp → MUST use send_whatsapp_web_text_message (use to="user" for self-messages) - If platform is Discord → MUST use send_discord_message or send_discord_dm - If platform is Slack → MUST use send_slack_message -- If platform is CraftBot TUI (or no platform specified) → use send_message +- If platform is CraftBot interface (or no platform specified) → use send_message - ONLY fall back to send_message if the platform's send action is not in the available actions list. -- send_message is for local TUI display ONLY. It does NOT reach external platforms. +- send_message is for local interface display ONLY. It does NOT reach external platforms. Third-Party Message Handling: - Third-party messages show as "[Incoming X message from NAME]" in event stream. @@ -67,7 +67,7 @@ Preferred Platform Routing (for notifications): - Check USER.md for "Preferred Messaging Platform" setting when notifying user. - For notifications about third-party messages, use preferred platform if available. -- If preferred platform's send action is unavailable, fall back to send_message (TUI). +- If preferred platform's send action is unavailable, fall back to send_message (interface). @@ -164,9 +164,9 @@ - If platform is WhatsApp → MUST use send_whatsapp_web_text_message (use to="user" for self-messages) - If platform is Discord → MUST use send_discord_message or send_discord_dm - If platform is Slack → MUST use send_slack_message -- If platform is CraftBot TUI (or no platform specified) → use send_message +- If platform is CraftBot interface (or no platform specified) → use send_message - ONLY fall back to send_message if the platform's send action is not in the available actions list. -- send_message is for local TUI display ONLY. It does NOT reach external platforms. +- send_message is for local interface display ONLY. It does NOT reach external platforms. Adaptive Execution: - If you lack information during EXECUTE, go back to COLLECT phase (add new collect todos) @@ -395,9 +395,9 @@ - If platform is WhatsApp → MUST use send_whatsapp_web_text_message (use to="user" for self-messages) - If platform is Discord → MUST use send_discord_message or send_discord_dm - If platform is Slack → MUST use send_slack_message -- If platform is CraftBot TUI (or no platform specified) → use send_message +- If platform is CraftBot interface (or no platform specified) → use send_message - ONLY fall back to send_message if the platform's send action is not in the available actions list. -- send_message is for local TUI display ONLY. It does NOT reach external platforms. +- send_message is for local interface display ONLY. It does NOT reach external platforms. Action Selection: - Choose the most direct action to accomplish the goal diff --git a/agent_core/core/prompts/context.py b/agent_core/core/prompts/context.py index 4e00db1b..55d3d6cc 100644 --- a/agent_core/core/prompts/context.py +++ b/agent_core/core/prompts/context.py @@ -25,6 +25,8 @@ - You are a proactive agent, that means you will proactively trying to make user's life easier and help them achieve their goals. - You have autonomous proactive capabilities that allow you to execute tasks without explicit user requests. This system enables scheduled operations, automated monitoring, and all computer-based tasks. +IMPORTANT: For any computer-based task the user requests, do not decline by saying you lack the capability. You have full access to the computer and can operate it like a human. Always find a way to complete the task. +DO NOT decline a user request with phrases like, “I don't have access to XXX” or “I can't XXX directly.” Instead, use the all tools available to you, adapt the approach as needed, and make a best effort to complete the task. IMPORTANT: You can to start a task to have more access to these capabilities. @@ -85,139 +87,22 @@ -Efficient File Reading: -- read_file returns content with line numbers (cat -n format) -- Default limit is 2000 lines - check has_more in response to know if file continues -- For large files (>500 lines), follow this strategy: - 1. Read beginning first to understand structure - 2. Use grep_files to find specific patterns/functions - 3. Use read_file with offset/limit to read targeted sections based on grep results - -File Actions: -- read_file: General reading with pagination (offset/limit) -- grep_files: Search for keywords, returns matching chunks with line numbers -- stream_read + stream_edit: Use together for file modifications - -Avoid: Reading entire large files repeatedly - use grep + targeted offset/limit reads instead +For detailed file handling instructions, read the "File Handling" section in AGENT.md using `read_file` or `grep_files`. +Key actions: read_file (with offset/limit), grep_files (search patterns), stream_read + stream_edit (modifications). - -- Follow the -- When told to generate a report, the default file format should be PDF unless specified otherwise - - -You are a self-improving agent. When you encounter a capability gap, proactively expand your abilities using the following mechanisms. +- You are a self-improving agent. +- You have the ability to configure your own MCPs, Skills, LLM provider/model and external apps connection. +- When you encounter a capability gap, read the "Self-Improvement Protocol" section in AGENT.md for detailed instructions. -## Self-Improvement Workflow -When you CANNOT complete a task due to missing capabilities: -1. IDENTIFY - What capability is missing? -2. SEARCH - Use `web_search` to find MCP servers or skills that provide the capability -3. INSTALL - Edit config files or clone repositories to install the solution -4. WAIT - The system will automatically detect the file change and hot-reload the new capability -5. CONTINUE - Proceed with the task using the new capability -6. REMEMBER - Store the solution in memory for future reference +Quick Reference - Config files (all auto-reload on change): +- MCP servers: `app/config/mcp_config.json` +- Skills: `app/config/skills_config.json` + `skills/` directory +- Integrations: `app/config/external_comms_config.json` +- Model/Settings/API keys: `app/config/settings.json` IMPORTANT: Always inform the user when you install new capabilities. Ask for permission if the installation requires credentials or has security implications. - -## Automatic Hot-Reload -All configuration files are monitored for changes. When you edit any config file, the system automatically detects the change and reloads the configuration within ~1 second. No manual reload actions or restart required. - -Monitored config files: -- `app/config/settings.json` - Settings (API keys, model config, OAuth credentials) -- `app/config/mcp_config.json` - MCP server connections -- `app/config/skills_config.json` - Skill configurations -- `app/config/external_comms_config.json` - Communication platform integrations - -## 1. MCP - Install New Tools -Config file: `app/config/mcp_config.json` - -When you lack a capability (e.g., cannot access a service, need a specific tool): -1. Use `read_file` to check existing MCP servers in `app/config/mcp_config.json` -2. Use `web_search` to find MCP servers: search " MCP server" or "modelcontextprotocol " -3. Use `stream_edit` to add new server entry to the `mcp_servers` array in `app/config/mcp_config.json` -4. Set `"enabled": true` to activate the server -5. The system will automatically detect the change and connect to the new server - -MCP server entry format: -```json -{ - "name": "server-name", - "description": "What this server does", - "transport": "stdio", - "command": "npx", - "args": ["-y", "@org/server-package"], - "env": {"API_KEY": ""}, - "enabled": true -} -``` - -Common patterns: -- NPX packages: `"command": "npx", "args": ["-y", "@modelcontextprotocol/server-name"]` -- Python servers: `"command": "uv", "args": ["run", "--directory", "/path/to/server", "main.py"]` -- HTTP/SSE servers: `"transport": "sse", "url": "http://localhost:3000/mcp"` - -## 2. Skill - Install Workflows and Instructions -Config file: `app/config/skills_config.json` -Skills directory: `skills/` - -When you need specialized workflows or domain knowledge: -1. Use `read_file` to check `app/config/skills_config.json` for existing skills -2. Use `web_search` to find skills: search "SKILL.md " or " agent skill github" -3. Use `run_shell` to clone the skill repository into the `skills/` directory: - `git clone https://github.com/user/skill-repo skills/skill-name` -4. Use `stream_edit` to add the skill name to `enabled_skills` array in `app/config/skills_config.json` -5. The system will automatically detect the change and load the new skill - -## 3. App - Configure Integrations -Config file: `app/config/external_comms_config.json` - -When you need to connect to communication platforms: -1. Use `read_file` to check current config in `app/config/external_comms_config.json` -2. Use `stream_edit` to update the platform configuration: - - Set required credentials (bot_token, api_key, phone_number, etc.) - - Set `"enabled": true` to activate -3. The system will automatically detect the change and start/stop platform connections - -Supported platforms: -- Telegram: bot mode (bot_token) or user mode (api_id, api_hash, phone_number) -- WhatsApp: web mode (session_id) or API mode (phone_number_id, access_token) - -## 4. Model & API Keys - Configure Providers -Config file: `app/config/settings.json` - -When you need different model capabilities or need to set API keys: -1. Use `read_file` to check current settings in `app/config/settings.json` -2. If the target model has no API key, you MUST ask the user for one. Without a valid API key, all LLM requests will fail. -3. Use `stream_edit` to update model configuration and/or API keys: -```json -{ - "model": { - "llm_provider": "anthropic", - "vlm_provider": "anthropic", - "llm_model": "claude-sonnet-4-20250514", - "vlm_model": "claude-sonnet-4-20250514" - }, - "api_keys": { - "openai": "sk-...", - "anthropic": "sk-ant-...", - "google": "...", - "byteplus": "..." - } -} -``` -4. The system will automatically detect the change and update settings (model changes take effect in new tasks) - -Available providers: openai, anthropic, gemini, byteplus, remote (Ollama) - -## 5. Memory - Learn and Remember -When you learn something useful (user preferences, project context, solutions to problems): -- Use `memory_search` action to check if relevant memory already exists -- Store important learnings in MEMORY.md via memory processing actions -- Use `read_file` to read USER.md and AGENT.md to understand context before tasks -- Use `stream_edit` to update USER.md with user preferences you discover -- Use `stream_edit` to update AGENT.md with operational improvements - @@ -250,13 +135,12 @@ - When you identify a proactive opportunity: 1. Acknowledge the potential for automation 2. Ask the user if they would like you to set up a proactive task (can be recurring task, one-time immediate task, or one-time task scheduled for later) - 3. If approved, use `proactive_add` action to add recurring task to PROACTIVE.md or `schedule_task` action to add one-time proactive task. + 3. If approved, use `recurring_add` action to add recurring task to PROACTIVE.md or `schedule_task` action to add one-time proactive task. 4. Confirm the setup with the user IMPORTANT: DO NOT automatically create proactive tasks without user consent. Always ask first. """ - POLICY_PROMPT = """ 1. Safety & Compliance: @@ -328,6 +212,12 @@ """ +AGENT_PROFILE_PROMPT = """ + +{agent_profile_content} + +""" + ENVIRONMENTAL_CONTEXT_PROMPT = """ - User Location: {user_location} @@ -389,12 +279,23 @@ """ +LANGUAGE_INSTRUCTION = """ + +Use the user's preferred language as specified in their profile above and USER.md. +- This applies to: all messages, task names (task_start), reasoning, file outputs, and more (anything that is presented to the user). +- Keep code, config files, agent-specific files (like USER.md, AGENT.md, MEMORY.md, and more), and technical identifiers in English or mixed when necessary. +- You can update the USER.md to change their preferred langauge when instructed by user. + +""" + __all__ = [ "AGENT_ROLE_PROMPT", "AGENT_INFO_PROMPT", "POLICY_PROMPT", "USER_PROFILE_PROMPT", + "AGENT_PROFILE_PROMPT", "ENVIRONMENTAL_CONTEXT_PROMPT", "AGENT_FILE_SYSTEM_CONTEXT_PROMPT", "GUI_MODE_PROMPT", + "LANGUAGE_INSTRUCTION", ] diff --git a/agent_core/core/protocols/action.py b/agent_core/core/protocols/action.py index ee30e484..8d1cb2eb 100644 --- a/agent_core/core/protocols/action.py +++ b/agent_core/core/protocols/action.py @@ -242,15 +242,3 @@ async def execute_action( Result dictionary with outputs and status. """ ... - - def get_action_history(self, limit: int = 10) -> list: - """ - Get recent action history. - - Args: - limit: Maximum number of entries to return. - - Returns: - List of action history entries. - """ - ... diff --git a/agent_core/core/protocols/database.py b/agent_core/core/protocols/database.py index 4cd8c98a..1af74166 100644 --- a/agent_core/core/protocols/database.py +++ b/agent_core/core/protocols/database.py @@ -7,10 +7,7 @@ typing for database operations across different agent implementations. """ -from typing import Any, Dict, List, Optional, Protocol, TYPE_CHECKING - -if TYPE_CHECKING: - from agent_core import Task +from typing import Any, Dict, List, Optional, Protocol class DatabaseInterfaceProtocol(Protocol): @@ -21,150 +18,72 @@ class DatabaseInterfaceProtocol(Protocol): must provide for use by shared agent code. """ - def log_task(self, task: "Task") -> None: - """ - Persist or update a task log entry. - - Args: - task: The Task instance to record. - """ - ... - - def upsert_action_history( + def list_actions( self, - run_id: str, *, - session_id: str, - parent_id: Optional[str], - name: str, - action_type: str, - status: str, - inputs: Optional[Dict[str, Any]], - outputs: Optional[Dict[str, Any]], - started_at: Optional[str], - ended_at: Optional[str], - ) -> None: + default: Optional[bool] = None, + ) -> List[Dict[str, Any]]: """ - Insert or update an action execution history entry. + Return stored actions optionally filtered by the default flag. Args: - run_id: Unique identifier for the action execution. - session_id: Session that triggered the action. - parent_id: Optional parent action identifier. - name: Human-readable action name. - action_type: Action type label. - status: Current execution status. - inputs: Serialized action inputs. - outputs: Serialized action outputs. - started_at: ISO timestamp for execution start. - ended_at: ISO timestamp for execution end. + default: When provided, only return actions whose default field + matches the boolean value. + + Returns: + List of action dictionaries that satisfy the filter. """ ... - async def log_action_start_async( - self, - run_id: str, - *, - session_id: Optional[str], - parent_id: Optional[str], - name: str, - action_type: str, - inputs: Optional[Dict[str, Any]], - started_at: str, - ) -> None: + def get_action(self, name: str) -> Optional[Dict[str, Any]]: """ - Fast O(1) append for action start (async version). + Fetch a stored action by name. Args: - run_id: Unique identifier for the action execution. - session_id: Session that triggered the action. - parent_id: Optional parent action identifier. - name: Human-readable action name. - action_type: Action type label. - inputs: Serialized action inputs. - started_at: ISO timestamp for execution start. + name: The human-readable name used to identify the action. + + Returns: + The action dictionary when found, otherwise None. """ ... - async def log_action_end_async( - self, - run_id: str, - *, - outputs: Optional[Dict[str, Any]], - status: str, - ended_at: str, - ) -> None: + def store_action(self, action_dict: Dict[str, Any]) -> None: """ - Fast O(1) append for action end (async version). + Persist an action definition to disk. Args: - run_id: Unique identifier for the action execution. - outputs: Serialized action outputs. - status: Final execution status. - ended_at: ISO timestamp for execution end. + action_dict: Action payload to store, expected to include a name + field used for the filename. """ ... - def get_action_history(self, limit: int = 10) -> List[Dict[str, Any]]: + def delete_action(self, name: str) -> None: """ - Retrieve recent action history entries. + Remove an action definition from disk. Args: - limit: Maximum number of entries to return. - - Returns: - List of action history dictionaries. + name: Name of the action to delete. """ ... - def find_actions_by_status(self, status: str) -> List[Dict[str, Any]]: + def set_agent_info(self, info: Dict[str, Any], key: str = "singleton") -> None: """ - Return all action history entries matching the given status. + Persist arbitrary agent configuration under the provided key. Args: - status: Status value to filter. - - Returns: - List of matching action history dictionaries. + info: Mapping of configuration fields to store. + key: Logical namespace under which the configuration is saved. """ ... - def search_actions(self, query: str, top_k: int = 7) -> List[str]: + def get_agent_info(self, key: str = "singleton") -> Optional[Dict[str, Any]]: """ - Search actions by semantic similarity. + Load persisted agent configuration for the given key. Args: - query: Search query string. - top_k: Maximum number of results. + key: Namespace key used when persisting the configuration. Returns: - List of action names matching the query. - """ - ... - - def log_prompt( - self, - *, - input_data: Dict[str, str], - output: Optional[str], - provider: str, - model: str, - config: Dict[str, Any], - status: str, - token_count_input: Optional[int] = None, - token_count_output: Optional[int] = None, - ) -> None: - """ - Store a prompt interaction with metadata. - - Args: - input_data: Serialized prompt inputs. - output: The model output string. - provider: Name of the LLM provider. - model: Model identifier used. - config: Provider-specific configuration. - status: Execution status. - token_count_input: Token count for prompt. - token_count_output: Token count for response. + A configuration dictionary when present, otherwise None. """ ... diff --git a/agent_core/core/protocols/event_stream.py b/agent_core/core/protocols/event_stream.py index 756d4e93..e4c18a57 100644 --- a/agent_core/core/protocols/event_stream.py +++ b/agent_core/core/protocols/event_stream.py @@ -64,7 +64,7 @@ def to_prompt_snapshot( ... def summarize_if_needed(self) -> None: - """Trigger summarization when threshold exceeded.""" + """Trigger summarization when threshold exceeded (synchronous, blocking).""" ... def mark_session_synced(self, call_type: str) -> None: diff --git a/agent_core/core/registry/database.py b/agent_core/core/registry/database.py index 6659d03d..ab04e20d 100644 --- a/agent_core/core/registry/database.py +++ b/agent_core/core/registry/database.py @@ -15,7 +15,7 @@ # In shared code: db = DatabaseRegistry.get() - db.log_task(task) + db.list_actions() """ from typing import TYPE_CHECKING diff --git a/agent_core/core/task/task.py b/agent_core/core/task/task.py index 189623fe..f63a526e 100644 --- a/agent_core/core/task/task.py +++ b/agent_core/core/task/task.py @@ -67,6 +67,8 @@ class Task: token_count: int = 0 # UUID for the task-level "divisible" action on the chatserver (CraftBot) chatserver_action_id: Optional[str] = None + # Whether the task is waiting for user reply (pauses trigger scheduling) + waiting_for_user_reply: bool = False def get_current_todo(self) -> Optional[TodoItem]: """ @@ -111,6 +113,7 @@ def to_dict(self) -> Dict[str, Any]: "action_count": self.action_count, "token_count": self.token_count, "chatserver_action_id": self.chatserver_action_id, + "waiting_for_user_reply": self.waiting_for_user_reply, } @classmethod @@ -136,4 +139,5 @@ def from_dict(cls, data: Dict[str, Any]) -> "Task": action_count=data.get("action_count", 0), token_count=data.get("token_count", 0), chatserver_action_id=data.get("chatserver_action_id"), + waiting_for_user_reply=data.get("waiting_for_user_reply", False), ) diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md index 26d1155d..426f8b5d 100644 --- a/agent_file_system/AGENT.md +++ b/agent_file_system/AGENT.md @@ -14,6 +14,136 @@ Errors are normal. How you handle them determines success. - If you find yourself stuck in a loop - the same action failing repeatedly with the same error - recognize this pattern and break out. Either try a fundamentally different approach or inform the user that you are blocked. - Never continue executing actions indefinitely when they are not making progress. This wastes resources and frustrates users. +## File Handling + +Efficient File Reading: +- read_file returns content with line numbers (cat -n format) +- Default limit is 2000 lines - check has_more in response to know if file continues +- For large files (>500 lines), follow this strategy: + 1. Read beginning first to understand structure + 2. Use grep_files to find specific patterns/functions + 3. Use read_file with offset/limit to read targeted sections based on grep results + +File Actions: +- read_file: General reading with pagination (offset/limit) +- grep_files: Search for keywords, returns matching chunks with line numbers +- stream_read + stream_edit: Use together for file modifications + +Avoid: Reading entire large files repeatedly - use grep + targeted offset/limit reads instead + +## Self-Improvement Protocol + +You are a self-improving agent. When you encounter a capability gap, proactively expand your abilities using the following mechanisms. + +### Self-Improvement Workflow +When you CANNOT complete a task due to missing capabilities: +1. IDENTIFY - What capability is missing? +2. SEARCH - Use `web_search` to find MCP servers or skills that provide the capability +3. INSTALL - Edit config files or clone repositories to install the solution +4. WAIT - The system will automatically detect the file change and hot-reload the new capability +5. CONTINUE - Proceed with the task using the new capability +6. REMEMBER - Store the solution in memory for future reference + +IMPORTANT: Always inform the user when you install new capabilities. Ask for permission if the installation requires credentials or has security implications. + +### Automatic Hot-Reload +All configuration files are monitored for changes. When you edit any config file, the system automatically detects the change and reloads the configuration within ~1 second. No manual reload actions or restart required. + +Monitored config files: +- `app/config/settings.json` - Settings (API keys, model config, OAuth credentials) +- `app/config/mcp_config.json` - MCP server connections +- `app/config/skills_config.json` - Skill configurations +- `app/config/external_comms_config.json` - Communication platform integrations + +### 1. MCP - Install New Tools +Config file: `app/config/mcp_config.json` + +When you lack a capability (e.g., cannot access a service, need a specific tool): +1. Use `read_file` to check existing MCP servers in `app/config/mcp_config.json` +2. Use `web_search` to find MCP servers: search " MCP server" or "modelcontextprotocol " +3. Use `stream_edit` to add new server entry to the `mcp_servers` array in `app/config/mcp_config.json` +4. Set `"enabled": true` to activate the server +5. The system will automatically detect the change and connect to the new server + +MCP server entry format: +```json +{ + "name": "server-name", + "description": "What this server does", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@org/server-package"], + "env": {"API_KEY": ""}, + "enabled": true +} +``` + +Common patterns: +- NPX packages: `"command": "npx", "args": ["-y", "@modelcontextprotocol/server-name"]` +- Python servers: `"command": "uv", "args": ["run", "--directory", "/path/to/server", "main.py"]` +- HTTP/SSE servers: `"transport": "sse", "url": "http://localhost:3000/mcp"` + +### 2. Skill - Install Workflows and Instructions +Config file: `app/config/skills_config.json` +Skills directory: `skills/` + +When you need specialized workflows or domain knowledge: +1. Use `read_file` to check `app/config/skills_config.json` for existing skills +2. Use `web_search` to find skills: search "SKILL.md " or " agent skill github" +3. Use `run_shell` to clone the skill repository into the `skills/` directory: + `git clone https://github.com/user/skill-repo skills/skill-name` +4. Use `stream_edit` to add the skill name to `enabled_skills` array in `app/config/skills_config.json` +5. The system will automatically detect the change and load the new skill + +### 3. App - Configure Integrations +Config file: `app/config/external_comms_config.json` + +When you need to connect to communication platforms: +1. Use `read_file` to check current config in `app/config/external_comms_config.json` +2. Use `stream_edit` to update the platform configuration: + - Set required credentials (bot_token, api_key, phone_number, etc.) + - Set `"enabled": true` to activate +3. The system will automatically detect the change and start/stop platform connections + +Supported platforms: +- Telegram: bot mode (bot_token) or user mode (api_id, api_hash, phone_number) +- WhatsApp: web mode (session_id) or API mode (phone_number_id, access_token) + +### 4. Model & API Keys - Configure Providers +Config file: `app/config/settings.json` + +When you need different model capabilities or need to set API keys: +1. Use `read_file` to check current settings in `app/config/settings.json` +2. If the target model has no API key, you MUST ask the user for one. Without a valid API key, all LLM requests will fail. +3. Use `stream_edit` to update model configuration and/or API keys: +```json +{ + "model": { + "llm_provider": "anthropic", + "vlm_provider": "anthropic", + "llm_model": "claude-sonnet-4-20250514", + "vlm_model": "claude-sonnet-4-20250514" + }, + "api_keys": { + "openai": "sk-...", + "anthropic": "sk-ant-...", + "google": "...", + "byteplus": "..." + } +} +``` +4. The system will automatically detect the change and update settings (model changes take effect in new tasks) + +Available providers: openai, anthropic, gemini, byteplus, remote (Ollama) + +### 5. Memory - Learn and Remember +When you learn something useful (user preferences, project context, solutions to problems): +- Use `memory_search` action to check if relevant memory already exists +- Store important learnings in MEMORY.md via memory processing actions +- Use `read_file` to read USER.md and AGENT.md to understand context before tasks +- Use `stream_edit` to update USER.md with user preferences you discover +- Use `stream_edit` to update AGENT.md with operational improvements + ## Proactive Behavior You activate on schedules (hourly/daily/weekly/monthly). diff --git a/app/agent_base.py b/app/agent_base.py index 8511989c..206b9dc1 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -98,7 +98,7 @@ class TriggerData: parent_id: str | None session_id: str | None = None user_message: str | None = None # Original user message without routing prefix - platform: str | None = None # Source platform (e.g., "CraftBot TUI", "Telegram", "Whatsapp") + platform: str | None = None # Source platform (e.g., "CraftBot Interface", "Telegram", "Whatsapp") is_self_message: bool = False # True when the user sent themselves a message contact_id: str | None = None # Sender/chat ID from external platform channel_id: str | None = None # Channel/group ID from external platform @@ -150,7 +150,6 @@ def __init__( provider=llm_provider, api_key=llm_api_key, base_url=llm_base_url, - db_interface=self.db_interface, deferred=deferred_init, ) self.vlm = VLMInterface( @@ -245,7 +244,7 @@ def __init__( context_engine=self.context_engine, ) - # Initialize footage callback (will be set by TUI interface later) + # Initialize footage callback (will be set by CraftBot interface later) self._tui_footage_callback = None # Only initialize GUIModule if GUI mode is globally enabled @@ -270,6 +269,7 @@ def __init__( # ── misc ── self.is_running: bool = True self._interface_mode: str = "tui" # Will be updated in run() based on selected interface + self.ui_controller = None # Set by interface after UIController is created self._extra_system_prompt: str = self._load_extra_system_prompt() # Scheduler for periodic tasks (memory processing, proactive checks, etc.) @@ -382,6 +382,20 @@ async def react(self, trigger: Trigger) -> None: # Use platform from trigger_data (already formatted by _extract_trigger_data) self.state_manager.record_user_message(user_message, platform=trigger_data.platform) + # Check if task is waiting for user reply but no message was received + # In this case, re-schedule the wait trigger instead of executing actions + if session_id and self.task_manager and not user_message: + task = self.task_manager.tasks.get(session_id) + if task and task.waiting_for_user_reply: + logger.info(f"[REACT] Task {session_id} is waiting for user reply but no message received. Re-scheduling wait trigger.") + # Re-schedule the wait trigger with another 3-hour delay + await self._create_new_trigger( + session_id, + {"fire_at_delay": 10800, "wait_for_user_reply": True}, # 3 hours + STATE + ) + return + # Debug: Log state after session initialization logger.debug( f"[STATE] session_id={session_id} | " @@ -564,10 +578,10 @@ async def _handle_memory_processing_trigger(self) -> bool: def _extract_trigger_data(self, trigger: Trigger) -> TriggerData: """Extract and structure data from trigger.""" # Extract platform from payload (already formatted by _handle_chat_message) - # Default to "CraftBot TUI" for local messages without platform info + # Default to "CraftBot Interface" for local messages without platform info payload = trigger.payload or {} raw_platform = payload.get("platform", "") - platform = raw_platform if raw_platform else "CraftBot TUI" + platform = raw_platform if raw_platform else "CraftBot Interface" return TriggerData( query=trigger.next_action_description, @@ -582,21 +596,20 @@ def _extract_trigger_data(self, trigger: Trigger) -> TriggerData: ) def _extract_user_message_from_trigger(self, trigger: Trigger) -> Optional[str]: - """Extract user message that was appended by triggers.fire(). + """Extract and consume user message that was stored by triggers.fire(). When a message is routed to an existing session, the fire() method - appends it as '[NEW USER MESSAGE]: {message}' to next_action_description. - This message needs to be recorded to the event stream so the LLM can see it. + stores it in the trigger's payload. This message needs to be recorded + to the event stream so the LLM can see it. + + Uses pop() to consume the message, preventing it from being carried + forward to subsequent triggers via create_new_trigger(). Returns: The user message if found, None otherwise. """ - marker = "[NEW USER MESSAGE]:" - desc = trigger.next_action_description - if marker in desc: - idx = desc.index(marker) + len(marker) - return desc[idx:].strip() - return None + payload = trigger.payload or {} + return payload.pop("pending_user_message", None) async def _initialize_session(self, gui_mode: bool | None, session_id: str) -> None: """Initialize the agent session and set current task ID. @@ -684,31 +697,48 @@ async def _handle_proactive_workflow(self, trigger: Trigger) -> bool: return False async def _handle_proactive_heartbeat(self, frequency: str) -> bool: - """Create heartbeat processing task for the given frequency.""" + """Create a unified heartbeat task that checks all due tasks. + + A single heartbeat runs hourly and collects due tasks across all + frequencies (hourly, daily, weekly, monthly) so only one schedule + entry is needed in scheduler_config.json. + + Args: + frequency: Ignored (kept for backward-compat with old configs + that still pass a single frequency). + """ import time - # Check if there are any tasks for this frequency - tasks = self.proactive_manager.get_tasks(frequency=frequency, enabled_only=True) - if not tasks: - logger.info(f"[PROACTIVE] No {frequency} tasks enabled, skipping heartbeat") + # Collect due tasks across ALL frequencies + all_due_tasks = self.proactive_manager.get_all_due_tasks() + if not all_due_tasks: + logger.info("[PROACTIVE] No due tasks across any frequency, skipping heartbeat") return False - # Create task using heartbeat-processor skill + # Build a concise summary for the task instruction + freq_counts = {} + for t in all_due_tasks: + freq_counts[t.frequency] = freq_counts.get(t.frequency, 0) + 1 + summary = ", ".join(f"{cnt} {freq}" for freq, cnt in freq_counts.items()) + task_id = self.task_manager.create_task( - task_name=f"{frequency.title()} Heartbeat", - task_instruction=f"Execute {frequency} proactive tasks from PROACTIVE.md. " - f"There are {len(tasks)} task(s) to process.", + task_name="Heartbeat", + task_instruction=( + f"Execute all due proactive tasks from PROACTIVE.md. " + f"Due tasks: {summary} ({len(all_due_tasks)} total). " + f"Use recurring_read with frequency='all' and enabled_only=true, " + f"then filter by each task's time/day fields." + ), mode="simple", - action_sets=["file_operations", "proactive"], + action_sets=["file_operations", "proactive", "web_research"], selected_skills=["heartbeat-processor"], ) - logger.info(f"[PROACTIVE] Created heartbeat task: {task_id} for {frequency}") + logger.info(f"[PROACTIVE] Created unified heartbeat task: {task_id} ({summary})") - # Queue trigger to start the task trigger = Trigger( fire_at=time.time(), priority=50, - next_action_description=f"Execute {frequency} proactive tasks", + next_action_description=f"Execute due proactive tasks ({summary})", session_id=task_id, payload={}, ) @@ -1108,6 +1138,11 @@ def _merge_action_outputs(self, outputs: list) -> dict: (output.get("fire_at_delay", 0.0) for output in outputs), default=0.0 ) + # Preserve wait_for_user_reply if any action sets it to True + merged["wait_for_user_reply"] = any( + output.get("wait_for_user_reply", False) for output in outputs + ) + # Check for errors errors = [o for o in outputs if o.get("status") == "error"] if errors: @@ -1124,6 +1159,16 @@ async def _finalize_action_execution( if not await self._check_agent_limits(): return + # Update task's waiting_for_user_reply flag based on action output + wait_for_reply = action_output.get("wait_for_user_reply", False) + task_id = new_session_id or session_id + if task_id and self.task_manager: + task = self.task_manager.tasks.get(task_id) + if task: + task.waiting_for_user_reply = wait_for_reply + if wait_for_reply: + logger.info(f"[TASK] Task {task_id} is now waiting for user reply") + # Check if parallel actions created multiple tasks parallel_results = action_output.get("parallel_results") if parallel_results: @@ -1307,19 +1352,23 @@ async def _create_new_trigger(self, new_session_id, action_output, STATE): fire_at = time.time() + fire_at_delay + # Check if this trigger should be marked as waiting for user reply + wait_for_user_reply = action_output.get("wait_for_user_reply", False) + logger.debug(f"[TRIGGER] Creating new trigger for session: {new_session_id}") # Check if there's a pending user message from fire() that needs to be carried forward pending_message, pending_platform = self.triggers.pop_pending_user_message(new_session_id) - if pending_message: - next_action_desc = f"Perform the next best action for the task based on the todos and event stream\n\n[NEW USER MESSAGE]: {pending_message}" - else: - next_action_desc = "Perform the next best action for the task based on the todos and event stream" - # Build payload with platform if available + # Keep description clean - pending messages go in payload + next_action_desc = "Perform the next best action for the task based on the todos and event stream" + + # Build payload - carry forward pending message if present trigger_payload = {"gui_mode": STATE.gui_mode} + if pending_message: + trigger_payload["pending_user_message"] = pending_message if pending_platform: - trigger_payload["platform"] = pending_platform + trigger_payload["pending_platform"] = pending_platform # Build and enqueue trigger safely try: @@ -1330,6 +1379,7 @@ async def _create_new_trigger(self, new_session_id, action_output, STATE): next_action_description=next_action_desc, session_id=new_session_id, payload=trigger_payload, + waiting_for_reply=wait_for_user_reply, ), skip_merge=True, # Session is already explicitly set, no LLM merge check needed ) @@ -1433,6 +1483,39 @@ def _format_sessions_for_routing( return "\n\n".join(sections) + async def _generate_unique_session_id(self) -> str: + """Generate a unique 6-character session ID. + + Creates a short session ID using the first 6 hex characters of a UUID4. + Checks for duplicates against running tasks and queued/active triggers. + + Returns: + A unique 6-character hex string session ID. + """ + max_attempts = 100 # Prevent infinite loop in edge cases + for _ in range(max_attempts): + candidate = uuid.uuid4().hex[:6] + + # Check against running tasks + existing_task_ids = set(self.task_manager.tasks.keys()) + + # Check against queued triggers + queued_triggers = await self.triggers.list_triggers() + queued_session_ids = {t.session_id for t in queued_triggers if t.session_id} + + # Check against active triggers (being processed) + active_session_ids = set(self.triggers._active.keys()) + + # Combine all existing IDs + all_existing_ids = existing_task_ids | queued_session_ids | active_session_ids + + if candidate not in all_existing_ids: + return candidate + + # Fallback to full UUID if somehow all short IDs are taken (extremely unlikely) + logger.warning("Could not generate unique 6-char session ID after 100 attempts, using full UUID") + return uuid.uuid4().hex + async def _route_to_session( self, item_type: str, @@ -1491,13 +1574,73 @@ async def _handle_chat_message(self, payload: Dict): # Determine platform - use payload's platform if available, otherwise default # External messages (WhatsApp, Telegram, etc.) have platform set by _handle_external_event - # TUI/CLI messages don't have platform in payload, so use "CraftBot TUI" + # Interface/CLI messages don't have platform in payload, so use "CraftBot Interface" if payload.get("platform"): # External message - capitalize for display (e.g., "whatsapp" -> "Whatsapp") platform = payload["platform"].capitalize() else: - # Local TUI/CLI message - platform = "CraftBot TUI" + # Local Interface/CLI message + platform = "CraftBot Interface" + + # Direct reply bypass - skip routing LLM when target_session_id is provided + target_session_id = payload.get("target_session_id") + if target_session_id: + logger.info(f"[CHAT] Direct reply to session {target_session_id}") + + # Fire the target trigger directly, bypassing routing LLM + fired = await self.triggers.fire( + target_session_id, message=chat_content, platform=platform + ) + + if fired: + logger.info(f"[CHAT] Successfully resumed session {target_session_id}") + + # Reset task's waiting_for_user_reply flag + if self.task_manager: + task = self.task_manager.tasks.get(target_session_id) + if task and task.waiting_for_user_reply: + task.waiting_for_user_reply = False + logger.info(f"[TASK] Task {target_session_id} no longer waiting for user reply") + + # Reset task status from "waiting" to "running" when user replies + if self.ui_controller: + from app.ui_layer.events import UIEvent, UIEventType + + self.ui_controller.event_bus.emit( + UIEvent( + type=UIEventType.TASK_UPDATE, + data={ + "task_id": target_session_id, + "status": "running", + }, + ) + ) + + # Check if there are still other tasks waiting + triggers = await self.triggers.list_triggers() + has_waiting_tasks = any( + getattr(t, 'waiting_for_reply', False) + for t in triggers + if t.session_id != target_session_id + ) + if not has_waiting_tasks: + self.ui_controller.event_bus.emit( + UIEvent( + type=UIEventType.AGENT_STATE_CHANGED, + data={ + "state": "working", + "status_message": "Agent is working...", + }, + ) + ) + + return # Task will resume with user message in event stream + + # If fire() returns False, no waiting trigger found for this session + # Fall through to normal routing (conversation mode) + logger.warning( + f"[CHAT] Session {target_session_id} not found or expired, falling through to normal routing" + ) # Check active tasks — route message to matching session if possible # Use active_task_ids from state_manager (not just triggers in queue) to ensure @@ -1530,6 +1673,56 @@ async def _handle_chat_message(self, payload: Dict): f"[CHAT] Routed message to existing session {matched_session_id} " f"(fired={fired}, reason: {routing_result.get('reason', 'N/A')})" ) + + # Reset task's waiting_for_user_reply flag + if self.task_manager: + task = self.task_manager.tasks.get(matched_session_id) + if task and task.waiting_for_user_reply: + task.waiting_for_user_reply = False + logger.info(f"[TASK] Task {matched_session_id} no longer waiting for user reply") + + # Reset task status from "waiting" to "running" when user replies + # Update UI regardless of fire() result - user has replied so we should + # acknowledge it. If fire() failed, the task may be stale but we still + # want to reset the waiting indicator. + if self.ui_controller: + from app.ui_layer.events import UIEvent, UIEventType + + self.ui_controller.event_bus.emit( + UIEvent( + type=UIEventType.TASK_UPDATE, + data={ + "task_id": matched_session_id, + "status": "running", + }, + ) + ) + + # Check if there are still other tasks waiting + # If not, update global agent state back to working + triggers = await self.triggers.list_triggers() + has_waiting_tasks = any( + getattr(t, 'waiting_for_reply', False) + for t in triggers + if t.session_id != matched_session_id + ) + if not has_waiting_tasks: + self.ui_controller.event_bus.emit( + UIEvent( + type=UIEventType.AGENT_STATE_CHANGED, + data={ + "state": "working", + "status_message": "Agent is working...", + }, + ) + ) + + if not fired: + logger.warning( + f"[CHAT] Trigger not found for session {matched_session_id} - " + "message may not be delivered to task" + ) + # Always trust routing decision - don't create new session return @@ -1553,7 +1746,7 @@ async def _handle_chat_message(self, payload: Dict): # the correct platform-specific send action for replies. # Must be directive (not just informational) for weaker LLMs. platform_hint = "" - if platform and platform.lower() != "craftbot tui": + if platform and platform.lower() != "craftbot interface": platform_hint = f" from {platform} (reply on {platform}, NOT send_message)" await self.triggers.put( @@ -1564,7 +1757,7 @@ async def _handle_chat_message(self, payload: Dict): "Please perform action that best suit this user chat " f"you just received{platform_hint}: {chat_content}" ), - session_id=str(uuid.uuid4()), # Generate unique session ID + session_id=await self._generate_unique_session_id(), payload=trigger_payload, ), skip_merge=True, @@ -2244,6 +2437,13 @@ def print_startup_step(step: int, total: int, message: str): ) await self.scheduler.start() + # Register scheduler_config for hot-reload (after scheduler is initialized) + config_watcher.register( + scheduler_config_path, + self.scheduler.reload, + name="scheduler_config.json" + ) + # Trigger soft onboarding if needed (BEFORE starting interface) # This ensures agent handles onboarding logic, not the interfaces from app.onboarding import onboarding_manager diff --git a/app/browser/interface.py b/app/browser/interface.py index 37e5845d..1b89e73d 100644 --- a/app/browser/interface.py +++ b/app/browser/interface.py @@ -46,6 +46,7 @@ def __init__( enable_action_panel=True, # Browser has action panel ) self._controller = UIController(agent, self._config) + agent.ui_controller = self._controller # Back-reference for event emission # Create browser adapter self._adapter = BrowserAdapter(self._controller) diff --git a/app/cli/interface.py b/app/cli/interface.py index 7d7fc098..8af4ce66 100644 --- a/app/cli/interface.py +++ b/app/cli/interface.py @@ -46,6 +46,7 @@ def __init__( enable_action_panel=False, # CLI uses inline action display ) self._controller = UIController(agent, self._config) + agent.ui_controller = self._controller # Back-reference for event emission # Create CLI adapter self._adapter = CLIAdapter(self._controller) diff --git a/app/config.py b/app/config.py index c9e32c80..9bf11943 100644 --- a/app/config.py +++ b/app/config.py @@ -187,6 +187,50 @@ def reload_settings() -> Dict[str, Any]: return get_settings(reload=True) +def save_settings(settings: Dict[str, Any]) -> None: + """Save settings to settings.json. + + Args: + settings: Dictionary with settings to save. + """ + global _settings_cache + _settings_cache = settings + SETTINGS_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(SETTINGS_CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2, ensure_ascii=False) + + +def get_os_language() -> str: + """Get OS language from settings. + + Returns: + Language code (e.g., "en", "ja", "zh") or "en" if not set. + """ + settings = get_settings() + return settings.get("general", {}).get("os_language", "en") + + +def detect_and_save_os_language() -> str: + """Detect OS language and save to settings. Called on first launch only. + + Returns: + Detected language code (e.g., "en", "ja", "zh"). + """ + import locale + + try: + system_locale = locale.getdefaultlocale()[0] or "en_US" + lang_code = system_locale.split("_")[0] # e.g., "en", "ja", "zh" + except Exception: + lang_code = "en" + + # Save to settings.json + settings = get_settings() + settings.setdefault("general", {})["os_language"] = lang_code + save_settings(settings) + return lang_code + + MAX_ACTIONS_PER_TASK: int = 500 MAX_TOKEN_PER_TASK: int = 12000000 # of tokens diff --git a/app/config/scheduler_config.json b/app/config/scheduler_config.json index 87ee6af5..546025fd 100644 --- a/app/config/scheduler_config.json +++ b/app/config/scheduler_config.json @@ -18,33 +18,12 @@ } }, { - "id": "hourly-heartbeat", - "name": "Hourly Heartbeat", - "instruction": "Execute hourly proactive tasks from PROACTIVE.md", - "schedule": "every 1 hours", + "id": "heartbeat", + "name": "Heartbeat", + "instruction": "Execute all due proactive tasks from PROACTIVE.md across all frequencies", + "schedule": "0,30 * * * *", "enabled": true, - "priority": 60, - "mode": "simple", - "recurring": true, - "action_sets": [ - "file_operations", - "proactive" - ], - "skills": [ - "heartbeat-processor" - ], - "payload": { - "type": "proactive_heartbeat", - "frequency": "hourly" - } - }, - { - "id": "daily-heartbeat", - "name": "Daily Heartbeat", - "instruction": "Execute daily proactive tasks from PROACTIVE.md", - "schedule": "every day at 8am", - "enabled": true, - "priority": 40, + "priority": 50, "mode": "simple", "recurring": true, "action_sets": [ @@ -56,50 +35,7 @@ "heartbeat-processor" ], "payload": { - "type": "proactive_heartbeat", - "frequency": "daily" - } - }, - { - "id": "weekly-heartbeat", - "name": "Weekly Heartbeat", - "instruction": "Execute weekly proactive tasks from PROACTIVE.md", - "schedule": "every sunday at 6pm", - "enabled": true, - "priority": 45, - "mode": "simple", - "recurring": true, - "action_sets": [ - "file_operations", - "proactive" - ], - "skills": [ - "heartbeat-processor" - ], - "payload": { - "type": "proactive_heartbeat", - "frequency": "weekly" - } - }, - { - "id": "monthly-heartbeat", - "name": "Monthly Heartbeat", - "instruction": "Execute monthly proactive tasks from PROACTIVE.md", - "schedule": "0 9 1 * *", - "enabled": true, - "priority": 50, - "mode": "simple", - "recurring": true, - "action_sets": [ - "file_operations", - "proactive" - ], - "skills": [ - "heartbeat-processor" - ], - "payload": { - "type": "proactive_heartbeat", - "frequency": "monthly" + "type": "proactive_heartbeat" } }, { diff --git a/app/config/settings.json b/app/config/settings.json index 403e9084..01fe2b83 100644 --- a/app/config/settings.json +++ b/app/config/settings.json @@ -1,18 +1,19 @@ { "general": { - "agent_name": "CraftBot" + "agent_name": "CraftBot", + "os_language": "en" }, "proactive": { - "enabled": false + "enabled": true }, "memory": { "enabled": true }, "model": { - "llm_provider": "gemini", - "vlm_provider": "gemini", - "llm_model": null, - "vlm_model": null + "llm_provider": "byteplus", + "vlm_provider": "byteplus", + "llm_model": "kimi-k2-250905", + "vlm_model": "seed-1-6-250915" }, "api_keys": { "openai": "", @@ -24,7 +25,8 @@ "remote_model_url": "", "byteplus_base_url": "https://ark.ap-southeast.bytepluses.com/api/v3", "google_api_base": "", - "google_api_version": "" + "google_api_version": "", + "remote": "http://localhost:11434" }, "gui": { "enabled": true, @@ -63,5 +65,11 @@ "browser": { "port": 7926, "startup_ui": false + }, + "api_keys_configured": { + "openai": false, + "anthropic": false, + "google": true, + "byteplus": true } } \ No newline at end of file diff --git a/app/credentials/handlers.py b/app/credentials/handlers.py index c8eceb31..3848b800 100644 --- a/app/credentials/handlers.py +++ b/app/credentials/handlers.py @@ -76,8 +76,8 @@ async def login(self, args): "code_challenge": code_challenge, "code_challenge_method": "S256", } - from agent_core import run_oauth_flow - code, error = run_oauth_flow(f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}") + from agent_core import run_oauth_flow_async + code, error = await run_oauth_flow_async(f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}") if error: return False, f"Google OAuth failed: {error}" token_data = { @@ -141,8 +141,8 @@ async def invite(self, args): scopes = "chat:write,channels:read,channels:history,groups:read,groups:history,users:read,files:write,im:read,im:write,im:history" params = {"client_id": SLACK_SHARED_CLIENT_ID, "scope": scopes, "redirect_uri": REDIRECT_URI_HTTPS, "state": secrets.token_urlsafe(32)} - from agent_core import run_oauth_flow - code, error = run_oauth_flow(f"https://slack.com/oauth/v2/authorize?{urlencode(params)}", use_https=True) + from agent_core import run_oauth_flow_async + code, error = await run_oauth_flow_async(f"https://slack.com/oauth/v2/authorize?{urlencode(params)}", use_https=True) if error: return False, f"Slack OAuth failed: {error}" import aiohttp @@ -206,8 +206,8 @@ async def invite(self, args): return False, "CraftOS Notion integration not configured. Set NOTION_SHARED_CLIENT_ID and NOTION_SHARED_CLIENT_SECRET env vars.\nAlternatively, use /notion login with your own integration token." params = {"client_id": NOTION_SHARED_CLIENT_ID, "redirect_uri": REDIRECT_URI, "response_type": "code", "owner": "user", "state": secrets.token_urlsafe(32)} - from agent_core import run_oauth_flow - code, error = run_oauth_flow(f"https://api.notion.com/v1/oauth/authorize?{urlencode(params)}") + from agent_core import run_oauth_flow_async + code, error = await run_oauth_flow_async(f"https://api.notion.com/v1/oauth/authorize?{urlencode(params)}") if error: return False, f"Notion OAuth failed: {error}" import aiohttp @@ -264,8 +264,8 @@ async def login(self, args): return False, "Not configured. Set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET env vars." params = {"response_type": "code", "client_id": LINKEDIN_CLIENT_ID, "redirect_uri": REDIRECT_URI, "scope": "openid profile email w_member_social", "state": secrets.token_urlsafe(32)} - from agent_core import run_oauth_flow - code, error = run_oauth_flow(f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}") + from agent_core import run_oauth_flow_async + code, error = await run_oauth_flow_async(f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}") if error: return False, f"LinkedIn OAuth failed: {error}" import aiohttp @@ -818,8 +818,8 @@ async def login(self, args): "code_challenge": code_challenge, "code_challenge_method": "S256", } - from agent_core import run_oauth_flow - code, error = run_oauth_flow( + from agent_core import run_oauth_flow_async + code, error = await run_oauth_flow_async( f"https://login.microsoftonline.com/common/oauth2/v2.0/authorize?{urlencode(params)}" ) if error: @@ -916,6 +916,218 @@ async def status(self): return True, f"WhatsApp Business: Connected\n - Phone Number ID: {pid}" +# ═══════════════════════════════════════════════════════════════════ +# Jira (API token) +# ═══════════════════════════════════════════════════════════════════ + +class JiraHandler(IntegrationHandler): + async def login(self, args): + if len(args) < 3: + return False, "Usage: /jira login \nGet an API token from https://id.atlassian.com/manage-profile/security/api-tokens" + domain, email, api_token = args[0], args[1], args[2] + + # Normalize domain + clean_domain = domain.strip().rstrip("/") + if clean_domain.startswith("https://"): + clean_domain = clean_domain[len("https://"):] + if clean_domain.startswith("http://"): + clean_domain = clean_domain[len("http://"):] + # Auto-append .atlassian.net if user only entered the subdomain + if "." not in clean_domain: + clean_domain = f"{clean_domain}.atlassian.net" + + email = email.strip() + api_token = api_token.strip() + + # Validate by calling /myself (try API v3, then v2 as fallback) + import httpx as _httpx + raw_auth = base64.b64encode(f"{email}:{api_token}".encode()).decode() + auth_headers = {"Authorization": f"Basic {raw_auth}", "Accept": "application/json"} + + data = None + last_status = 0 + try: + for api_ver in ("3", "2"): + url = f"https://{clean_domain}/rest/api/{api_ver}/myself" + logger.info(f"[Jira] Trying {url} with email={email}") + r = _httpx.get(url, headers=auth_headers, timeout=15, follow_redirects=True) + if r.status_code == 200: + data = r.json() + break + body = r.text + logger.warning(f"[Jira] API v{api_ver} returned HTTP {r.status_code}: {body[:300]}") + last_status = r.status_code + + if data is None: + hints = [f"Tried: https://{clean_domain}/rest/api/3/myself"] + if last_status == 401: + hints.append("Ensure you are using an API token, not your account password.") + hints.append("The email must match your Atlassian account email exactly.") + hints.append("Generate a token at: https://id.atlassian.com/manage-profile/security/api-tokens") + elif last_status == 403: + hints.append("Your account may not have REST API access. Check Jira permissions.") + elif last_status == 404: + hints.append(f"Domain '{clean_domain}' not reachable or has no REST API.") + hint_str = "\n".join(f" - {h}" for h in hints) + return False, f"Jira auth failed (HTTP {last_status}).\n{hint_str}" + except _httpx.ConnectError: + return False, f"Cannot connect to https://{clean_domain} — check the domain name." + except Exception as e: + return False, f"Jira connection error: {e}" + + from app.external_comms.platforms.jira import JiraCredential + save_credential("jira.json", JiraCredential( + domain=clean_domain, + email=email, + api_token=api_token, + )) + display_name = data.get("displayName", email) + return True, f"Jira connected as {display_name} ({clean_domain})" + + async def logout(self, args): + if not has_credential("jira.json"): + return False, "No Jira credentials found." + try: + from app.external_comms.manager import get_external_comms_manager + manager = get_external_comms_manager() + if manager: + await manager.stop_platform("jira") + except Exception: + pass + remove_credential("jira.json") + return True, "Removed Jira credential." + + async def status(self): + if not has_credential("jira.json"): + return True, "Jira: Not connected" + from app.external_comms.platforms.jira import JiraCredential + cred = load_credential("jira.json", JiraCredential) + if not cred: + return True, "Jira: Not connected" + domain = cred.domain or cred.site_url or "unknown" + email = cred.email or "OAuth" + labels = cred.watch_labels + label_info = f" [watching: {', '.join(labels)}]" if labels else "" + return True, f"Jira: Connected\n - {email} ({domain}){label_info}" + + +# ═══════════════════════════════════════════════════════════════════ +# GitHub (personal access token) +# ═══════════════════════════════════════════════════════════════════ + +class GitHubHandler(IntegrationHandler): + async def login(self, args): + if not args: + return False, "Usage: /github login \nGenerate one at: https://github.com/settings/tokens" + token = args[0].strip() + + import httpx as _httpx + try: + r = _httpx.get( + "https://api.github.com/user", + headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, + timeout=15, + ) + if r.status_code != 200: + return False, f"GitHub auth failed (HTTP {r.status_code}). Check your token." + data = r.json() + except Exception as e: + return False, f"GitHub connection error: {e}" + + from app.external_comms.platforms.github import GitHubCredential + save_credential("github.json", GitHubCredential( + access_token=token, + username=data.get("login", ""), + )) + return True, f"GitHub connected as @{data.get('login')} ({data.get('name', '')})" + + async def logout(self, args): + if not has_credential("github.json"): + return False, "No GitHub credentials found." + try: + from app.external_comms.manager import get_external_comms_manager + manager = get_external_comms_manager() + if manager: + await manager.stop_platform("github") + except Exception: + pass + remove_credential("github.json") + return True, "Removed GitHub credential." + + async def status(self): + if not has_credential("github.json"): + return True, "GitHub: Not connected" + from app.external_comms.platforms.github import GitHubCredential + cred = load_credential("github.json", GitHubCredential) + if not cred: + return True, "GitHub: Not connected" + username = cred.username or "unknown" + tag = cred.watch_tag + tag_info = f" [tag: {tag}]" if tag else "" + repos_info = f" [repos: {', '.join(cred.watch_repos)}]" if cred.watch_repos else "" + return True, f"GitHub: Connected\n - @{username}{tag_info}{repos_info}" + + +# ═══════════════════════════════════════════════════════════════════ +# Twitter/X (API key + secret + access tokens) +# ═══════════════════════════════════════════════════════════════════ + +class TwitterHandler(IntegrationHandler): + async def login(self, args): + if len(args) < 4: + return False, "Usage: /twitter login \nGet these from developer.x.com" + api_key, api_secret, access_token, access_token_secret = args[0].strip(), args[1].strip(), args[2].strip(), args[3].strip() + + # Validate by calling /users/me + try: + from app.external_comms.platforms.twitter import TwitterCredential, _oauth1_header + import httpx as _httpx + + url = "https://api.twitter.com/2/users/me" + params = {"user.fields": "id,name,username"} + auth_hdr = _oauth1_header("GET", url, params, api_key, api_secret, access_token, access_token_secret) + r = _httpx.get(url, headers={"Authorization": auth_hdr}, params=params, timeout=15) + if r.status_code != 200: + return False, f"Twitter auth failed (HTTP {r.status_code}). Check your API credentials.\nGet them from developer.x.com → Dashboard → Keys and tokens" + data = r.json().get("data", {}) + except Exception as e: + return False, f"Twitter connection error: {e}" + + save_credential("twitter.json", TwitterCredential( + api_key=api_key, + api_secret=api_secret, + access_token=access_token, + access_token_secret=access_token_secret, + user_id=data.get("id", ""), + username=data.get("username", ""), + )) + return True, f"Twitter/X connected as @{data.get('username')} ({data.get('name', '')})" + + async def logout(self, args): + if not has_credential("twitter.json"): + return False, "No Twitter credentials found." + try: + from app.external_comms.manager import get_external_comms_manager + manager = get_external_comms_manager() + if manager: + await manager.stop_platform("twitter") + except Exception: + pass + remove_credential("twitter.json") + return True, "Removed Twitter credential." + + async def status(self): + if not has_credential("twitter.json"): + return True, "Twitter/X: Not connected" + from app.external_comms.platforms.twitter import TwitterCredential + cred = load_credential("twitter.json", TwitterCredential) + if not cred: + return True, "Twitter/X: Not connected" + username = cred.username or "unknown" + tag_info = f" [tag: {cred.watch_tag}]" if cred.watch_tag else "" + return True, f"Twitter/X: Connected\n - @{username}{tag_info}" + + # ═══════════════════════════════════════════════════════════════════ # Registry # ═══════════════════════════════════════════════════════════════════ @@ -930,4 +1142,7 @@ async def status(self): "whatsapp": WhatsAppHandler(), "outlook": OutlookHandler(), "whatsapp_business": WhatsAppBusinessHandler(), + "jira": JiraHandler(), + "github": GitHubHandler(), + "twitter": TwitterHandler(), } diff --git a/app/data/action/github/github_actions.py b/app/data/action/github/github_actions.py new file mode 100644 index 00000000..a5d58b59 --- /dev/null +++ b/app/data/action/github/github_actions.py @@ -0,0 +1,308 @@ +from agent_core import action + + +_NO_CRED_MSG = "No GitHub credential. Use /github login first." + + +# ------------------------------------------------------------------ +# Issues +# ------------------------------------------------------------------ + +@action( + name="list_github_issues", + description="List issues for a GitHub repository.", + action_sets=["github"], + input_schema={ + "repo": {"type": "string", "description": "Repository in owner/repo format.", "example": "octocat/hello-world"}, + "state": {"type": "string", "description": "Filter by state: open, closed, all.", "example": "open"}, + "per_page": {"type": "integer", "description": "Max results.", "example": 30}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_github_issues(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.list_issues( + input_data["repo"], + state=input_data.get("state", "open"), + per_page=input_data.get("per_page", 30), + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_github_issue", + description="Get details of a specific GitHub issue or PR by number.", + action_sets=["github"], + input_schema={ + "repo": {"type": "string", "description": "Repository in owner/repo format.", "example": "octocat/hello-world"}, + "number": {"type": "integer", "description": "Issue or PR number.", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_github_issue(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.get_issue(input_data["repo"], input_data["number"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="create_github_issue", + description="Create a new issue in a GitHub repository.", + action_sets=["github"], + input_schema={ + "repo": {"type": "string", "description": "Repository in owner/repo format.", "example": "octocat/hello-world"}, + "title": {"type": "string", "description": "Issue title.", "example": "Bug: login fails"}, + "body": {"type": "string", "description": "Issue body (markdown).", "example": ""}, + "labels": {"type": "string", "description": "Comma-separated labels.", "example": "bug,urgent"}, + "assignees": {"type": "string", "description": "Comma-separated GitHub usernames to assign.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_github_issue(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + labels_str = input_data.get("labels", "") + labels = [l.strip() for l in labels_str.split(",") if l.strip()] if labels_str else None + assignees_str = input_data.get("assignees", "") + assignees = [a.strip() for a in assignees_str.split(",") if a.strip()] if assignees_str else None + result = await client.create_issue( + input_data["repo"], + input_data["title"], + body=input_data.get("body", ""), + labels=labels, + assignees=assignees, + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="close_github_issue", + description="Close a GitHub issue.", + action_sets=["github"], + input_schema={ + "repo": {"type": "string", "description": "Repository in owner/repo format.", "example": "octocat/hello-world"}, + "number": {"type": "integer", "description": "Issue number.", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def close_github_issue(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.close_issue(input_data["repo"], input_data["number"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Comments +# ------------------------------------------------------------------ + +@action( + name="add_github_comment", + description="Add a comment to a GitHub issue or PR.", + action_sets=["github"], + input_schema={ + "repo": {"type": "string", "description": "Repository in owner/repo format.", "example": "octocat/hello-world"}, + "number": {"type": "integer", "description": "Issue or PR number.", "example": 1}, + "body": {"type": "string", "description": "Comment body (markdown).", "example": "Fixed in commit abc123."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_github_comment(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.create_comment(input_data["repo"], input_data["number"], input_data["body"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Labels +# ------------------------------------------------------------------ + +@action( + name="add_github_labels", + description="Add labels to a GitHub issue or PR.", + action_sets=["github"], + input_schema={ + "repo": {"type": "string", "description": "Repository in owner/repo format.", "example": "octocat/hello-world"}, + "number": {"type": "integer", "description": "Issue or PR number.", "example": 1}, + "labels": {"type": "string", "description": "Comma-separated labels to add.", "example": "bug,priority-high"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_github_labels(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + labels = [l.strip() for l in input_data["labels"].split(",") if l.strip()] + if not labels: + return {"status": "error", "message": "No labels provided."} + result = await client.add_labels(input_data["repo"], input_data["number"], labels) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Pull Requests +# ------------------------------------------------------------------ + +@action( + name="list_github_prs", + description="List pull requests for a GitHub repository.", + action_sets=["github"], + input_schema={ + "repo": {"type": "string", "description": "Repository in owner/repo format.", "example": "octocat/hello-world"}, + "state": {"type": "string", "description": "Filter: open, closed, all.", "example": "open"}, + "per_page": {"type": "integer", "description": "Max results.", "example": 30}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_github_prs(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.list_pull_requests( + input_data["repo"], + state=input_data.get("state", "open"), + per_page=input_data.get("per_page", 30), + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Repos & Search +# ------------------------------------------------------------------ + +@action( + name="list_github_repos", + description="List repositories for the authenticated GitHub user.", + action_sets=["github"], + input_schema={ + "per_page": {"type": "integer", "description": "Max repos to return.", "example": 30}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_github_repos(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.list_repos(per_page=input_data.get("per_page", 30)) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="search_github_issues", + description="Search GitHub issues and PRs using GitHub search syntax.", + action_sets=["github"], + input_schema={ + "query": {"type": "string", "description": "GitHub search query (e.g. 'repo:owner/repo is:open label:bug').", "example": "repo:octocat/hello-world is:open"}, + "per_page": {"type": "integer", "description": "Max results.", "example": 20}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def search_github_issues(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.search_issues(input_data["query"], per_page=input_data.get("per_page", 20)) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Watch Settings +# ------------------------------------------------------------------ + +@action( + name="set_github_watch_tag", + description="Set a mention tag for the GitHub listener. Only comments containing this tag (e.g. '@craftbot') will trigger events.", + action_sets=["github"], + input_schema={ + "tag": {"type": "string", "description": "Tag to watch for. Empty = disabled.", "example": "@craftbot"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_github_watch_tag(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + tag = input_data.get("tag", "").strip() + client.set_watch_tag(tag) + if tag: + return {"status": "success", "message": f"Now only triggering on comments containing '{tag}'."} + return {"status": "success", "message": "Watch tag disabled. Triggering on all notifications."} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="set_github_watch_repos", + description="Set which repositories the GitHub listener watches. Only events from these repos will trigger.", + action_sets=["github"], + input_schema={ + "repos": {"type": "string", "description": "Comma-separated repos in owner/repo format. Empty = all repos.", "example": "octocat/hello-world,myorg/myrepo"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_github_watch_repos(input_data: dict) -> dict: + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + repos_str = input_data.get("repos", "") + repos = [r.strip() for r in repos_str.split(",") if r.strip()] if repos_str else [] + client.set_watch_repos(repos) + if repos: + return {"status": "success", "message": f"Watching repos: {', '.join(repos)}"} + return {"status": "success", "message": "Watching all repos."} + except Exception as e: + return {"status": "error", "message": str(e)} diff --git a/app/data/action/integration_management.py b/app/data/action/integration_management.py new file mode 100644 index 00000000..8b36df72 --- /dev/null +++ b/app/data/action/integration_management.py @@ -0,0 +1,522 @@ +""" +Actions for managing external app integrations (connect, disconnect, list, status). + +These actions allow the agent to help users connect to external apps like +WhatsApp, Telegram, Slack, Discord, etc. directly through conversation, +without requiring the user to navigate to settings in browser or terminal. +""" + +from agent_core import action + + +@action( + name="list_available_integrations", + description=( + "List all available external app integrations and their connection status. " + "Use this when the user asks what apps they can connect, wants to see which " + "integrations are available, or asks about their connected accounts. " + "Returns each integration's name, type, connection status, and connected accounts." + ), + default=True, + action_sets=["core"], + parallelizable=True, + input_schema={ + "filter_connected": { + "type": "boolean", + "description": "If true, only show connected integrations. If false, show all available integrations.", + "example": False, + }, + }, + output_schema={ + "status": { + "type": "string", + "example": "success", + "description": "Result status.", + }, + "integrations": { + "type": "array", + "description": "List of integration info objects.", + }, + "message": { + "type": "string", + "description": "Human-readable summary.", + }, + }, + test_payload={ + "filter_connected": False, + "simulated_mode": True, + }, +) +def list_available_integrations(input_data: dict) -> dict: + if input_data.get("simulated_mode"): + return {"status": "success", "integrations": [], "message": "Simulated mode"} + + try: + from app.external_comms.integration_settings import list_integrations + + integrations = list_integrations() + filter_connected = input_data.get("filter_connected", False) + + if filter_connected: + integrations = [i for i in integrations if i["connected"]] + + return { + "status": "success", + "integrations": integrations, + "message": f"Found {len(integrations)} integration(s).", + } + except Exception as e: + return {"status": "error", "integrations": [], "message": str(e)} + + +@action( + name="connect_integration", + description=( + "Connect an external app integration. Use this when the user wants to connect " + "to an external app such as WhatsApp, Telegram, Slack, Discord, Notion, LinkedIn, " + "Google Workspace, or others. " + "For token-based integrations (Telegram Bot, Discord, Slack, WhatsApp Business, Notion), " + "the user needs to provide their credentials/tokens - ask the user for the required " + "fields before calling this action. " + "For OAuth integrations (Google, LinkedIn, Slack invite), this will start the OAuth " + "flow and provide a URL for the user to open in their browser. " + "For interactive integrations (WhatsApp Web), this will start a QR code session " + "that the user needs to scan with their phone. " + "IMPORTANT: Before calling this action, first use list_available_integrations to " + "check which integrations are available and their auth requirements, then ask the " + "user for any required credentials." + ), + default=True, + action_sets=["core"], + parallelizable=True, + input_schema={ + "integration_id": { + "type": "string", + "description": ( + "The integration to connect. Valid values: slack, discord, telegram, " + "whatsapp, whatsapp_business, google, notion, linkedin." + ), + "example": "telegram", + }, + "credentials": { + "type": "object", + "description": ( + "Credentials for token-based auth. Keys depend on the integration: " + "slack: {bot_token, workspace_name(optional)}, " + "discord: {bot_token}, " + "telegram: {bot_token}, " + "whatsapp_business: {access_token, phone_number_id}, " + "notion: {token}. " + "Leave empty for OAuth or interactive (QR code) flows." + ), + "example": {"bot_token": "123456:ABC-DEF"}, + }, + "auth_method": { + "type": "string", + "description": ( + "Which auth method to use. 'token' for providing credentials directly, " + "'oauth' for browser-based OAuth flow, 'interactive' for QR code scan " + "(WhatsApp Web, Telegram user account). If not specified, the best " + "method is chosen automatically based on the integration type." + ), + "example": "token", + }, + }, + output_schema={ + "status": { + "type": "string", + "example": "success", + "description": "Result status: success, error, qr_ready, or oauth_started.", + }, + "message": { + "type": "string", + "description": "Human-readable result message.", + }, + "auth_type": { + "type": "string", + "description": "The auth type used for this connection.", + }, + "qr_code": { + "type": "string", + "description": "Base64 QR code image data (only for interactive/QR flows).", + }, + "session_id": { + "type": "string", + "description": "Session ID for QR code status polling (only for interactive flows).", + }, + "required_fields": { + "type": "array", + "description": "List of required credential fields if credentials were missing.", + }, + }, + test_payload={ + "integration_id": "telegram", + "credentials": {"bot_token": "test_token"}, + "simulated_mode": True, + }, +) +def connect_integration(input_data: dict) -> dict: + import asyncio + + if input_data.get("simulated_mode"): + return {"status": "success", "message": "Simulated mode", "auth_type": "token"} + + integration_id = input_data.get("integration_id", "").strip().lower() + credentials = input_data.get("credentials", {}) or {} + auth_method = input_data.get("auth_method", "").strip().lower() + + if not integration_id: + return {"status": "error", "message": "integration_id is required."} + + try: + from app.external_comms.integration_settings import ( + INTEGRATION_REGISTRY, + get_integration_fields, + connect_integration_token, + connect_integration_oauth, + connect_integration_interactive, + start_whatsapp_qr_session, + ) + + if integration_id not in INTEGRATION_REGISTRY: + available = ", ".join(INTEGRATION_REGISTRY.keys()) + return { + "status": "error", + "message": f"Unknown integration: '{integration_id}'. Available: {available}", + } + + info = INTEGRATION_REGISTRY[integration_id] + supported_auth = info["auth_type"] + + # Determine which auth method to use + if not auth_method: + if credentials: + auth_method = "token" + elif supported_auth == "oauth": + auth_method = "oauth" + elif supported_auth == "interactive": + auth_method = "interactive" + elif supported_auth == "token_with_interactive": + # If no credentials provided, default to token (user needs to provide them) + auth_method = "token" + elif supported_auth == "both": + # Default to token if credentials are provided, otherwise oauth + auth_method = "token" if credentials else "oauth" + else: + auth_method = "token" + + # --- Token-based connection --- + if auth_method == "token": + required_fields = get_integration_fields(integration_id) + + if not credentials and required_fields: + return { + "status": "needs_credentials", + "message": ( + f"To connect {info['name']}, please provide the following credentials." + ), + "auth_type": "token", + "required_fields": [ + { + "key": f["key"], + "label": f["label"], + "placeholder": f.get("placeholder", ""), + "is_secret": f.get("password", False), + } + for f in required_fields + ], + } + + # Validate required fields are present + missing = [] + for field in required_fields: + if field.get("password", False) or not field.get("placeholder", "").startswith("(optional"): + if not credentials.get(field["key"]): + # Check if the field is truly required (non-optional) + label = field.get("label", field["key"]) + if "optional" not in label.lower(): + missing.append(field) + + if missing: + return { + "status": "needs_credentials", + "message": "Some required credentials are missing.", + "auth_type": "token", + "required_fields": [ + { + "key": f["key"], + "label": f["label"], + "placeholder": f.get("placeholder", ""), + "is_secret": f.get("password", False), + } + for f in missing + ], + } + + loop = asyncio.new_event_loop() + try: + success, message = loop.run_until_complete( + connect_integration_token(integration_id, credentials) + ) + finally: + loop.close() + + return { + "status": "success" if success else "error", + "message": message, + "auth_type": "token", + } + + # --- OAuth-based connection --- + elif auth_method == "oauth": + if supported_auth not in ("oauth", "both"): + return { + "status": "error", + "message": f"OAuth is not supported for {info['name']}. Use token-based auth instead.", + "auth_type": supported_auth, + } + + loop = asyncio.new_event_loop() + try: + success, message = loop.run_until_complete( + connect_integration_oauth(integration_id) + ) + finally: + loop.close() + + return { + "status": "success" if success else "error", + "message": message, + "auth_type": "oauth", + } + + # --- Interactive (QR code) connection --- + elif auth_method == "interactive": + if supported_auth not in ("interactive", "token_with_interactive"): + return { + "status": "error", + "message": f"Interactive login is not supported for {info['name']}.", + "auth_type": supported_auth, + } + + # Special handling for WhatsApp QR code flow + if integration_id == "whatsapp": + loop = asyncio.new_event_loop() + try: + result = loop.run_until_complete(start_whatsapp_qr_session()) + finally: + loop.close() + + if result.get("success") and result.get("status") == "qr_ready": + return { + "status": "qr_ready", + "message": result.get("message", "Scan the QR code with WhatsApp on your phone."), + "auth_type": "interactive", + "qr_code": result.get("qr_code", ""), + "session_id": result.get("session_id", ""), + } + elif result.get("success") and result.get("status") == "connected": + return { + "status": "success", + "message": result.get("message", "WhatsApp connected successfully!"), + "auth_type": "interactive", + } + else: + return { + "status": "error", + "message": result.get("message", "Failed to start WhatsApp session."), + "auth_type": "interactive", + } + + # Generic interactive flow for other integrations (e.g., Telegram user) + loop = asyncio.new_event_loop() + try: + success, message = loop.run_until_complete( + connect_integration_interactive(integration_id) + ) + finally: + loop.close() + + return { + "status": "success" if success else "error", + "message": message, + "auth_type": "interactive", + } + + else: + return { + "status": "error", + "message": f"Unknown auth method: '{auth_method}'. Use 'token', 'oauth', or 'interactive'.", + } + + except Exception as e: + return {"status": "error", "message": f"Connection failed: {str(e)}"} + + +@action( + name="check_integration_status", + description=( + "Check the connection status of a specific integration, or check the status " + "of an ongoing WhatsApp QR code session. Use this to verify if an integration " + "is connected, or to poll whether a QR code has been scanned." + ), + default=True, + action_sets=["core"], + parallelizable=True, + input_schema={ + "integration_id": { + "type": "string", + "description": "The integration to check status for.", + "example": "telegram", + }, + "session_id": { + "type": "string", + "description": "Session ID for checking WhatsApp QR scan status (from connect_integration result).", + "example": "", + }, + }, + output_schema={ + "status": { + "type": "string", + "example": "success", + }, + "connected": { + "type": "boolean", + "description": "Whether the integration is currently connected.", + }, + "accounts": { + "type": "array", + "description": "List of connected accounts.", + }, + "message": { + "type": "string", + "description": "Human-readable status message.", + }, + }, + test_payload={ + "integration_id": "telegram", + "simulated_mode": True, + }, +) +def check_integration_status(input_data: dict) -> dict: + import asyncio + + if input_data.get("simulated_mode"): + return {"status": "success", "connected": False, "accounts": [], "message": "Simulated"} + + integration_id = input_data.get("integration_id", "").strip().lower() + session_id = input_data.get("session_id", "").strip() + + if not integration_id: + return {"status": "error", "message": "integration_id is required."} + + try: + # If a session_id is provided, check WhatsApp QR session status + if session_id and integration_id == "whatsapp": + from app.external_comms.integration_settings import check_whatsapp_session_status + + loop = asyncio.new_event_loop() + try: + result = loop.run_until_complete(check_whatsapp_session_status(session_id)) + finally: + loop.close() + + return { + "status": result.get("status", "error"), + "connected": result.get("connected", False), + "accounts": [], + "message": result.get("message", ""), + } + + # Otherwise check general integration status + from app.external_comms.integration_settings import get_integration_info + + info = get_integration_info(integration_id) + if not info: + return { + "status": "error", + "connected": False, + "accounts": [], + "message": f"Unknown integration: '{integration_id}'.", + } + + return { + "status": "success", + "connected": info["connected"], + "accounts": info.get("accounts", []), + "message": ( + f"{info['name']} is connected with {len(info.get('accounts', []))} account(s)." + if info["connected"] + else f"{info['name']} is not connected." + ), + } + except Exception as e: + return {"status": "error", "connected": False, "accounts": [], "message": str(e)} + + +@action( + name="disconnect_integration", + description=( + "Disconnect an external app integration. Use this when the user wants to " + "remove or disconnect a connected app like WhatsApp, Telegram, Slack, etc. " + "Optionally specify a specific account to disconnect if multiple are connected." + ), + default=True, + action_sets=["core"], + parallelizable=True, + input_schema={ + "integration_id": { + "type": "string", + "description": "The integration to disconnect.", + "example": "slack", + }, + "account_id": { + "type": "string", + "description": "Optional specific account ID to disconnect (if multiple accounts are connected).", + "example": "", + }, + }, + output_schema={ + "status": { + "type": "string", + "example": "success", + }, + "message": { + "type": "string", + "description": "Human-readable result message.", + }, + }, + test_payload={ + "integration_id": "slack", + "simulated_mode": True, + }, +) +def disconnect_integration(input_data: dict) -> dict: + import asyncio + + if input_data.get("simulated_mode"): + return {"status": "success", "message": "Simulated mode"} + + integration_id = input_data.get("integration_id", "").strip().lower() + account_id = input_data.get("account_id", "").strip() or None + + if not integration_id: + return {"status": "error", "message": "integration_id is required."} + + try: + from app.external_comms.integration_settings import disconnect_integration as _disconnect + + loop = asyncio.new_event_loop() + try: + success, message = loop.run_until_complete( + _disconnect(integration_id, account_id) + ) + finally: + loop.close() + + return { + "status": "success" if success else "error", + "message": message, + } + except Exception as e: + return {"status": "error", "message": f"Disconnect failed: {str(e)}"} diff --git a/app/data/action/jira/jira_actions.py b/app/data/action/jira/jira_actions.py new file mode 100644 index 00000000..9654bcfd --- /dev/null +++ b/app/data/action/jira/jira_actions.py @@ -0,0 +1,483 @@ +from agent_core import action + + +_NO_CRED_MSG = "No Jira credential. Use /jira login first." + + +def _jira_client(): + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return None + return client + + +# ------------------------------------------------------------------ +# Issues +# ------------------------------------------------------------------ + +@action( + name="search_jira_issues", + description="Search for Jira issues using JQL (Jira Query Language).", + action_sets=["jira"], + input_schema={ + "jql": {"type": "string", "description": "JQL query string.", "example": 'project = PROJ AND status = "In Progress"'}, + "max_results": {"type": "integer", "description": "Max issues to return (max 100).", "example": 20}, + "fields": {"type": "string", "description": "Comma-separated fields to return. Leave empty for defaults.", "example": "summary,status,assignee,priority"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def search_jira_issues(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + fields_str = input_data.get("fields", "") + fields_list = [f.strip() for f in fields_str.split(",") if f.strip()] if fields_str else None + result = await client.search_issues( + jql=input_data["jql"], + max_results=input_data.get("max_results", 20), + fields_list=fields_list, + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_jira_issue", + description="Get details of a specific Jira issue by its key (e.g. PROJ-123).", + action_sets=["jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "fields": {"type": "string", "description": "Comma-separated fields to return. Leave empty for all.", "example": "summary,status,assignee,description"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_issue(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + fields_str = input_data.get("fields", "") + fields_list = [f.strip() for f in fields_str.split(",") if f.strip()] if fields_str else None + result = await client.get_issue(input_data["issue_key"], fields_list=fields_list) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="create_jira_issue", + description="Create a new Jira issue in a project.", + action_sets=["jira"], + input_schema={ + "project_key": {"type": "string", "description": "Project key.", "example": "PROJ"}, + "summary": {"type": "string", "description": "Issue title/summary.", "example": "Fix login bug"}, + "issue_type": {"type": "string", "description": "Issue type name.", "example": "Task"}, + "description": {"type": "string", "description": "Issue description (plain text).", "example": ""}, + "assignee_id": {"type": "string", "description": "Atlassian account ID of the assignee. Leave empty for unassigned.", "example": ""}, + "labels": {"type": "string", "description": "Comma-separated labels.", "example": "bug,urgent"}, + "priority": {"type": "string", "description": "Priority name (e.g. High, Medium, Low).", "example": "Medium"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_jira_issue(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + labels_str = input_data.get("labels", "") + labels = [l.strip() for l in labels_str.split(",") if l.strip()] if labels_str else None + result = await client.create_issue( + project_key=input_data["project_key"], + summary=input_data["summary"], + issue_type=input_data.get("issue_type", "Task"), + description=input_data.get("description") or None, + assignee_id=input_data.get("assignee_id") or None, + labels=labels, + priority=input_data.get("priority") or None, + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="update_jira_issue", + description="Update fields on an existing Jira issue.", + action_sets=["jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "summary": {"type": "string", "description": "New summary. Leave empty to keep current.", "example": ""}, + "priority": {"type": "string", "description": "New priority name. Leave empty to keep current.", "example": ""}, + "labels": {"type": "string", "description": "Comma-separated labels to SET (replaces all). Leave empty to keep current.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_jira_issue(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + fields_update = {} + if input_data.get("summary"): + fields_update["summary"] = input_data["summary"] + if input_data.get("priority"): + fields_update["priority"] = {"name": input_data["priority"]} + if input_data.get("labels"): + fields_update["labels"] = [l.strip() for l in input_data["labels"].split(",") if l.strip()] + if not fields_update: + return {"status": "error", "message": "No fields to update."} + result = await client.update_issue(input_data["issue_key"], fields_update) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Comments +# ------------------------------------------------------------------ + +@action( + name="add_jira_comment", + description="Add a comment to a Jira issue.", + action_sets=["jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "body": {"type": "string", "description": "Comment text.", "example": "Fixed in latest commit."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_jira_comment(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.add_comment(input_data["issue_key"], input_data["body"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_jira_comments", + description="Get comments on a Jira issue.", + action_sets=["jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "max_results": {"type": "integer", "description": "Max comments to return.", "example": 20}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_comments(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.get_issue_comments( + input_data["issue_key"], + max_results=input_data.get("max_results", 20), + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Transitions +# ------------------------------------------------------------------ + +@action( + name="get_jira_transitions", + description="Get available status transitions for a Jira issue (to know which statuses you can move it to).", + action_sets=["jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_transitions(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.get_transitions(input_data["issue_key"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="transition_jira_issue", + description="Move a Jira issue to a new status. Use get_jira_transitions first to find the transition ID.", + action_sets=["jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "transition_id": {"type": "string", "description": "Transition ID from get_jira_transitions.", "example": "31"}, + "comment": {"type": "string", "description": "Optional comment to add with the transition.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def transition_jira_issue(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.transition_issue( + input_data["issue_key"], + input_data["transition_id"], + comment=input_data.get("comment") or None, + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Assignment +# ------------------------------------------------------------------ + +@action( + name="assign_jira_issue", + description="Assign a Jira issue to a user. Use search_jira_users to find the account ID.", + action_sets=["jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "account_id": {"type": "string", "description": "Atlassian account ID. Leave empty to unassign.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def assign_jira_issue(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.assign_issue( + input_data["issue_key"], + account_id=input_data.get("account_id") or None, + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Labels +# ------------------------------------------------------------------ + +@action( + name="add_jira_labels", + description="Add labels to a Jira issue without removing existing ones.", + action_sets=["jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "labels": {"type": "string", "description": "Comma-separated labels to add.", "example": "urgent,backend"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_jira_labels(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + labels = [l.strip() for l in input_data["labels"].split(",") if l.strip()] + if not labels: + return {"status": "error", "message": "No labels provided."} + result = await client.add_labels(input_data["issue_key"], labels) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="remove_jira_labels", + description="Remove labels from a Jira issue.", + action_sets=["jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "labels": {"type": "string", "description": "Comma-separated labels to remove.", "example": "urgent"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def remove_jira_labels(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + labels = [l.strip() for l in input_data["labels"].split(",") if l.strip()] + if not labels: + return {"status": "error", "message": "No labels provided."} + result = await client.remove_labels(input_data["issue_key"], labels) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Projects & Users +# ------------------------------------------------------------------ + +@action( + name="list_jira_projects", + description="List accessible Jira projects.", + action_sets=["jira"], + input_schema={ + "max_results": {"type": "integer", "description": "Max projects to return.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_jira_projects(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.get_projects(max_results=input_data.get("max_results", 50)) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="search_jira_users", + description="Search for Jira users by name or email.", + action_sets=["jira"], + input_schema={ + "query": {"type": "string", "description": "Search string (name or email).", "example": "john"}, + "max_results": {"type": "integer", "description": "Max results.", "example": 10}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def search_jira_users(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.search_users( + input_data["query"], + max_results=input_data.get("max_results", 10), + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Watch Tag (comment mention filter) +# ------------------------------------------------------------------ + +@action( + name="set_jira_watch_tag", + description="Set a mention tag to watch for in Jira comments. Only comments containing this tag (e.g. '@craftbot') will trigger events. Pass empty string to disable and receive all updates.", + action_sets=["jira"], + input_schema={ + "tag": {"type": "string", "description": "The mention tag to watch for in comments. e.g. '@craftbot'. Empty = disabled.", "example": "@craftbot"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_jira_watch_tag(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + tag = input_data.get("tag", "").strip() + client.set_watch_tag(tag) + if tag: + return {"status": "success", "message": f"Now only triggering on comments containing '{tag}'."} + return {"status": "success", "message": "Watch tag disabled. Triggering on all issue updates."} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_jira_watch_tag", + description="Get the current mention tag the Jira listener watches for in comments.", + action_sets=["jira"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_jira_watch_tag(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + tag = client.get_watch_tag() + if tag: + return {"status": "success", "tag": tag, "message": f"Watching for: '{tag}' in comments."} + return {"status": "success", "tag": "", "message": "No watch tag set. Triggering on all issue updates."} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Watch Labels (issue label filter) +# ------------------------------------------------------------------ + +@action( + name="set_jira_watch_labels", + description="Set which labels the Jira listener watches for. Only issues with these labels will trigger events. Pass empty to watch all issues.", + action_sets=["jira"], + input_schema={ + "labels": {"type": "string", "description": "Comma-separated labels to watch. Empty string = watch all issues.", "example": "craftos,agent-task"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_jira_watch_labels(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + labels_str = input_data.get("labels", "") + labels = [l.strip() for l in labels_str.split(",") if l.strip()] if labels_str else [] + client.set_watch_labels(labels) + if labels: + return {"status": "success", "message": f"Now watching issues with labels: {', '.join(labels)}"} + return {"status": "success", "message": "Now watching all issues (no label filter)."} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_jira_watch_labels", + description="Get the current label filter for the Jira listener.", + action_sets=["jira"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_jira_watch_labels(input_data: dict) -> dict: + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + labels = client.get_watch_labels() + if labels: + return {"status": "success", "labels": labels, "message": f"Watching: {', '.join(labels)}"} + return {"status": "success", "labels": [], "message": "Watching all issues (no label filter)."} + except Exception as e: + return {"status": "error", "message": str(e)} diff --git a/app/data/action/recurring_read.py b/app/data/action/recurring_read.py index 47097adf..569e85c7 100644 --- a/app/data/action/recurring_read.py +++ b/app/data/action/recurring_read.py @@ -64,6 +64,9 @@ def recurring_read(input_data: dict) -> dict: # Convert tasks to dictionaries task_list = [] for task in tasks: + # Calculate next_run dynamically (clock-aligned to heartbeat slots) + next_run = task.calculate_next_run() + task_dict = { "id": task.id, "name": task.name, @@ -80,8 +83,8 @@ def recurring_read(input_data: dict) -> dict: task_dict["day"] = task.day if task.last_run: task_dict["last_run"] = task.last_run.isoformat() - if task.next_run: - task_dict["next_run"] = task.next_run.isoformat() + if next_run: + task_dict["next_run"] = next_run.isoformat() if task.conditions: task_dict["conditions"] = [c.to_dict() for c in task.conditions] if task.outcome_history: diff --git a/app/data/action/run_python.py b/app/data/action/run_python.py index e9aba0f2..b37fa591 100644 --- a/app/data/action/run_python.py +++ b/app/data/action/run_python.py @@ -2,7 +2,7 @@ @action( name="run_python", - description="This action takes a single Python code snippet as input and executes it in a fresh environment. Missing packages are automatically detected and installed when ImportError occurs. This action is intended for cases when the AI agent needs to create a one-off solution dynamically.", + description="Execute a Python code snippet in an isolated environment. Missing packages are auto-installed. Use print() to return results.", execution_mode="sandboxed", mode="CLI", default=True, @@ -10,165 +10,86 @@ input_schema={ "code": { "type": "string", - "example": "import requests\nprint(requests.get('https://example.com').text)", - "description": "The Python code snippet to execute. Missing packages will be automatically installed on ImportError. The input code MUST NOT have any malicious code, the code MUST BE SANDBOXED. The code must be production code with the highest level of quality. DO NOT give any placeholder code or fabricated data. You MUST NOT handle exception with system exit. The result of the code return to the agent can only be returned with 'print'." + "example": "print('Hello World')", + "description": "Python code to execute. Use print() to output results." } }, output_schema={ "status": { "type": "string", - "example": "success", - "description": "'success' if the script ran without errors; otherwise 'error'." + "description": "'success' or 'error'" }, "stdout": { "type": "string", - "example": "Hello, World!", - "description": "Captured standard output from the script execution." + "description": "Output from print() statements" }, "stderr": { "type": "string", - "example": "Traceback (most recent call last): ...", - "description": "Captured standard error from the script execution (empty if no error)." + "description": "Error output (if any)" }, "message": { "type": "string", - "example": "Script executed successfully.", - "description": "A short message indicating the result of the script execution. Only present if status is 'error'." + "description": "Error message (only if status is 'error')" } }, - requirement=["traceback"], - test_payload={ - "code": "import subprocess, sys\nsubprocess.check_call([sys.executable, '-m', 'pip', 'install', '--quiet', 'requests'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\nimport requests\nprint(requests.get('https://example.com').text)", - "simulated_mode": True - } + requirement=[], + test_payload={"code": "print('test')", "simulated_mode": True} ) def create_and_run_python_script(input_data: dict) -> dict: - import json import sys - import subprocess import io import traceback + import subprocess import re - import importlib - code_snippet = input_data.get("code", "") - - def _ensure_utf8_stdio() -> None: - """Force stdout/stderr to UTF-8 so Unicode output doesn't break on Windows consoles.""" - for stream_name in ("stdout", "stderr"): - stream = getattr(sys, stream_name, None) - if hasattr(stream, "reconfigure"): - try: - stream.reconfigure(encoding="utf-8", errors="replace") - except Exception: - return { - "status": "error", - "stdout": "", - "stderr": "", - "message": "The 'utf-8' not supported." - } + code = input_data.get("code", "").strip() - _ensure_utf8_stdio() + if not code: + return {"status": "error", "stdout": "", "stderr": "", "message": "No code provided"} - if not code_snippet.strip(): - return { - "status": "error", - "stdout": "", - "stderr": "", - "message": "The 'code' field is required." - } - - stdout_capture = io.StringIO() - stderr_capture = io.StringIO() + # Capture stdout/stderr + stdout_buf = io.StringIO() + stderr_buf = io.StringIO() + old_stdout, old_stderr = sys.stdout, sys.stderr - def _install_package(pkg_name: str) -> bool: + def install_package(pkg): try: subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '--quiet', pkg_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=60 + [sys.executable, '-m', 'pip', 'install', '--quiet', pkg], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60 ) return True - except Exception: + except: return False - def _extract_imports(code: str) -> set: - imports = set() - # Match: import module, import module as alias, from module import ... - patterns = [ - r'^import\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)', - r'^from\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+import', - ] - for line in code.split('\n'): - line = line.strip() - if line.startswith('#') or not line: - continue - for pattern in patterns: - match = re.match(pattern, line) - if match: - module = match.group(1).split('.')[0] # Get top-level module - # Skip stdlib modules - if module not in ['json', 'sys', 'os', 'io', 'subprocess', 'traceback', 're', 'importlib', - 'urllib', 'collections', 'datetime', 'time', 'pathlib', 'tempfile']: - imports.add(module) - return imports - try: - original_stdout = sys.stdout - original_stderr = sys.stderr - sys.stdout = stdout_capture - sys.stderr = stderr_capture + sys.stdout, sys.stderr = stdout_buf, stderr_buf - # Pre-install packages detected from imports (optional optimization) - # This helps but we'll also handle ImportError at runtime - detected_imports = _extract_imports(code_snippet) - for pkg in detected_imports: + # Simple exec with retry for missing modules + for attempt in range(3): try: - importlib.import_module(pkg) - except ImportError: - _install_package(pkg) - - exec_globals = {} - max_retries = 3 - retry_count = 0 - - while retry_count < max_retries: - try: - exec(code_snippet, exec_globals) - break # Success, exit retry loop + exec(code, {"__builtins__": __builtins__}) + break except ModuleNotFoundError as e: - # Extract module name from error message - module_match = re.search(r"No module named ['\"]([^'\"]+)['\"]", str(e)) - if module_match: - missing_module = module_match.group(1).split('.')[0] # Get top-level module - if retry_count < max_retries - 1: - # Try to install the missing module - if _install_package(missing_module): - retry_count += 1 - continue # Retry execution - # If we can't install or max retries reached, raise the original error - raise - except Exception: - # For non-ImportError exceptions, don't retry + match = re.search(r"No module named ['\"]([^'\"]+)['\"]", str(e)) + if match and attempt < 2: + pkg = match.group(1).split('.')[0] + if install_package(pkg): + continue raise - sys.stdout = original_stdout - sys.stderr = original_stderr - + sys.stdout, sys.stderr = old_stdout, old_stderr return { "status": "success", - "stdout": stdout_capture.getvalue().strip(), - "stderr": stderr_capture.getvalue().strip() + "stdout": stdout_buf.getvalue().strip(), + "stderr": stderr_buf.getvalue().strip() } except Exception: - sys.stdout = original_stdout - sys.stderr = original_stderr - + sys.stdout, sys.stderr = old_stdout, old_stderr return { "status": "error", - "stdout": stdout_capture.getvalue().strip(), - "stderr": stderr_capture.getvalue().strip(), + "stdout": stdout_buf.getvalue().strip(), + "stderr": stderr_buf.getvalue().strip(), "message": traceback.format_exc() - } \ No newline at end of file + } diff --git a/app/data/action/schedule_task.py b/app/data/action/schedule_task.py index 51bad01b..4977ca59 100644 --- a/app/data/action/schedule_task.py +++ b/app/data/action/schedule_task.py @@ -82,10 +82,14 @@ } } ) -def schedule_task(input_data: dict) -> dict: +async def schedule_task(input_data: dict) -> dict: """Add a new scheduled task or queue an immediate trigger.""" import app.internal_action_interface as iai from datetime import datetime + import asyncio + import time + import uuid + from agent_core import Trigger scheduler = iai.InternalActionInterface.scheduler if scheduler is None: @@ -114,8 +118,7 @@ def schedule_task(input_data: dict) -> dict: # Handle immediate execution if schedule_expr.lower() == "immediate": - return _add_immediate_trigger( - scheduler=scheduler, + return await scheduler.queue_immediate_trigger( name=name, instruction=instruction, priority=priority, @@ -125,6 +128,47 @@ def schedule_task(input_data: dict) -> dict: payload=payload ) + session_id = f"immediate_{uuid.uuid4().hex[:8]}_{int(time.time())}" + + trigger_payload = { + "type": "scheduled", + "schedule_id": f"immediate_{uuid.uuid4().hex[:8]}", + "schedule_name": name, + "instruction": instruction, + "mode": mode, + "action_sets": action_sets, + "skills": skills, + **payload + } + + # TODO: Should not have to create additional trigger (create using queue_immediate_trigger) + # Workaround for now + trigger = Trigger( + fire_at=time.time(), + priority=priority, + next_action_description=f"[Immediate] {name}: {instruction}", + payload=trigger_payload, + session_id=session_id, + ) + + trigger_queue = scheduler._trigger_queue + if trigger_queue is None: + return {"status": "error", "error": "Trigger queue not initialized"} + + try: + loop = asyncio.get_running_loop() + asyncio.create_task(trigger_queue.put(trigger)) + except RuntimeError: + asyncio.run(trigger_queue.put(trigger)) + + return { + "status": "ok", + "schedule_id": session_id, + "name": name, + "scheduled_for": "immediate", + "message": f"Task '{name}' queued for immediate execution (session: {session_id})" + } + # Parse schedule to determine if it's recurring or one-time from app.scheduler.parser import ScheduleParser parsed = ScheduleParser.parse(schedule_expr) @@ -165,74 +209,3 @@ def schedule_task(input_data: dict) -> dict: "status": "error", "error": str(e) } - - -def _add_immediate_trigger( - scheduler, - name: str, - instruction: str, - priority: int, - mode: str, - action_sets: list, - skills: list, - payload: dict -) -> dict: - """ - Queue a trigger for immediate execution. - - This creates a new session and queues it to the TriggerQueue - for immediate processing by the scheduler. - """ - import asyncio - import time - import uuid - from agent_core import Trigger - - # Generate unique session ID - session_id = f"immediate_{uuid.uuid4().hex[:8]}_{int(time.time())}" - - # Build trigger payload (matching the format used by _fire_schedule) - trigger_payload = { - "type": "scheduled", - "schedule_id": f"immediate_{uuid.uuid4().hex[:8]}", - "schedule_name": name, - "instruction": instruction, - "mode": mode, - "action_sets": action_sets, - "skills": skills, - **payload - } - - # Create trigger - trigger = Trigger( - fire_at=time.time(), # Fire immediately - priority=priority, - next_action_description=f"[Immediate] {name}: {instruction}", - payload=trigger_payload, - session_id=session_id, - ) - - # Queue the trigger - trigger_queue = scheduler._trigger_queue - if trigger_queue is None: - return { - "status": "error", - "error": "Trigger queue not initialized" - } - - # Try to queue using running event loop, or create new one - try: - loop = asyncio.get_running_loop() - # We're in an async context, use create_task - asyncio.create_task(trigger_queue.put(trigger)) - except RuntimeError: - # No running event loop, use asyncio.run - asyncio.run(trigger_queue.put(trigger)) - - return { - "status": "ok", - "schedule_id": session_id, - "name": name, - "scheduled_for": "immediate", - "message": f"Task '{name}' queued for immediate execution (session: {session_id})" - } diff --git a/app/data/action/send_message.py b/app/data/action/send_message.py index b9cdb900..c2661b14 100644 --- a/app/data/action/send_message.py +++ b/app/data/action/send_message.py @@ -39,15 +39,19 @@ def send_message(input_data: dict) -> dict: import json import asyncio - + message = input_data['message'] wait_for_user_reply = bool(input_data.get('wait_for_user_reply', False)) simulated_mode = input_data.get('simulated_mode', False) - + # Extract session_id injected by ActionManager for multi-task isolation + session_id = input_data.get('_session_id') + # In simulated mode, skip the actual interface call for testing if not simulated_mode: import app.internal_action_interface as internal_action_interface - asyncio.run(internal_action_interface.InternalActionInterface.do_chat(message)) + asyncio.run(internal_action_interface.InternalActionInterface.do_chat( + message, session_id=session_id + )) fire_at_delay = 10800 if wait_for_user_reply else 0 # Return 'success' for test compatibility, but keep 'ok' in production if needed diff --git a/app/data/action/send_message_with_attachment.py b/app/data/action/send_message_with_attachment.py index a9e7cdbf..3662c3b2 100644 --- a/app/data/action/send_message_with_attachment.py +++ b/app/data/action/send_message_with_attachment.py @@ -60,6 +60,8 @@ def send_message_with_attachment(input_data: dict) -> dict: file_paths = input_data.get('file_paths', []) wait_for_user_reply = bool(input_data.get('wait_for_user_reply', False)) simulated_mode = input_data.get('simulated_mode', False) + # Extract session_id injected by ActionManager for multi-task isolation + session_id = input_data.get('_session_id') # Ensure file_paths is a list if isinstance(file_paths, str): @@ -78,7 +80,7 @@ def send_message_with_attachment(input_data: dict) -> dict: # Use the do_chat_with_attachments method which handles browser/CLI fallback result = asyncio.run(internal_action_interface.InternalActionInterface.do_chat_with_attachments( - message, file_paths + message, file_paths, session_id=session_id )) fire_at_delay = 10800 if wait_for_user_reply else 0 diff --git a/app/data/action/twitter/twitter_actions.py b/app/data/action/twitter/twitter_actions.py new file mode 100644 index 00000000..909e4b3c --- /dev/null +++ b/app/data/action/twitter/twitter_actions.py @@ -0,0 +1,232 @@ +from agent_core import action + + +_NO_CRED_MSG = "No Twitter/X credential. Use /twitter login first." + + +@action( + name="post_tweet", + description="Post a tweet on Twitter/X.", + action_sets=["twitter"], + input_schema={ + "text": {"type": "string", "description": "Tweet text (max 280 chars).", "example": "Hello world!"}, + "reply_to": {"type": "string", "description": "Tweet ID to reply to. Leave empty for a new tweet.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def post_tweet(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.post_tweet(input_data["text"], reply_to=input_data.get("reply_to") or None) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="reply_to_tweet", + description="Reply to a tweet on Twitter/X.", + action_sets=["twitter"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID to reply to.", "example": "1234567890"}, + "text": {"type": "string", "description": "Reply text.", "example": "Thanks for sharing!"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def reply_to_tweet(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.reply_to_tweet(input_data["tweet_id"], input_data["text"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="delete_tweet", + description="Delete a tweet.", + action_sets=["twitter"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID to delete.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_tweet(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.delete_tweet(input_data["tweet_id"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="search_tweets", + description="Search recent tweets on Twitter/X.", + action_sets=["twitter"], + input_schema={ + "query": {"type": "string", "description": "Search query.", "example": "from:elonmusk"}, + "max_results": {"type": "integer", "description": "Max results (10-100).", "example": 10}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def search_tweets(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.search_tweets(input_data["query"], max_results=input_data.get("max_results", 10)) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_twitter_timeline", + description="Get recent tweets from a user's timeline.", + action_sets=["twitter"], + input_schema={ + "user_id": {"type": "string", "description": "User ID. Leave empty for your own timeline.", "example": ""}, + "max_results": {"type": "integer", "description": "Max tweets to return.", "example": 10}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_twitter_timeline(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.get_user_timeline( + user_id=input_data.get("user_id") or None, + max_results=input_data.get("max_results", 10), + ) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="like_tweet", + description="Like a tweet on Twitter/X.", + action_sets=["twitter"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID to like.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def like_tweet(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.like_tweet(input_data["tweet_id"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="retweet", + description="Retweet a tweet on Twitter/X.", + action_sets=["twitter"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID to retweet.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def retweet(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.retweet(input_data["tweet_id"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_twitter_user", + description="Look up a Twitter/X user by username.", + action_sets=["twitter"], + input_schema={ + "username": {"type": "string", "description": "Twitter username (without @).", "example": "elonmusk"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_twitter_user(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.get_user_by_username(input_data["username"]) + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_twitter_me", + description="Get the authenticated Twitter/X user's profile.", + action_sets=["twitter"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_twitter_me(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + result = await client.get_me() + return {"status": "success", "result": result} + except Exception as e: + return {"status": "error", "message": str(e)} + + +# ------------------------------------------------------------------ +# Watch Settings +# ------------------------------------------------------------------ + +@action( + name="set_twitter_watch_tag", + description="Set a keyword the Twitter listener watches for in mentions. Only mentions containing this keyword will trigger events.", + action_sets=["twitter"], + input_schema={ + "tag": {"type": "string", "description": "Keyword to watch for. Empty = all mentions.", "example": "@craftbot"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_twitter_watch_tag(input_data: dict) -> dict: + try: + from app.external_comms.platforms.twitter import TwitterClient + client = TwitterClient() + if not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + tag = input_data.get("tag", "").strip() + client.set_watch_tag(tag) + if tag: + return {"status": "success", "message": f"Now only triggering on mentions containing '{tag}'."} + return {"status": "success", "message": "Watch tag disabled. Triggering on all mentions."} + except Exception as e: + return {"status": "error", "message": str(e)} diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index 26d1155d..426f8b5d 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -14,6 +14,136 @@ Errors are normal. How you handle them determines success. - If you find yourself stuck in a loop - the same action failing repeatedly with the same error - recognize this pattern and break out. Either try a fundamentally different approach or inform the user that you are blocked. - Never continue executing actions indefinitely when they are not making progress. This wastes resources and frustrates users. +## File Handling + +Efficient File Reading: +- read_file returns content with line numbers (cat -n format) +- Default limit is 2000 lines - check has_more in response to know if file continues +- For large files (>500 lines), follow this strategy: + 1. Read beginning first to understand structure + 2. Use grep_files to find specific patterns/functions + 3. Use read_file with offset/limit to read targeted sections based on grep results + +File Actions: +- read_file: General reading with pagination (offset/limit) +- grep_files: Search for keywords, returns matching chunks with line numbers +- stream_read + stream_edit: Use together for file modifications + +Avoid: Reading entire large files repeatedly - use grep + targeted offset/limit reads instead + +## Self-Improvement Protocol + +You are a self-improving agent. When you encounter a capability gap, proactively expand your abilities using the following mechanisms. + +### Self-Improvement Workflow +When you CANNOT complete a task due to missing capabilities: +1. IDENTIFY - What capability is missing? +2. SEARCH - Use `web_search` to find MCP servers or skills that provide the capability +3. INSTALL - Edit config files or clone repositories to install the solution +4. WAIT - The system will automatically detect the file change and hot-reload the new capability +5. CONTINUE - Proceed with the task using the new capability +6. REMEMBER - Store the solution in memory for future reference + +IMPORTANT: Always inform the user when you install new capabilities. Ask for permission if the installation requires credentials or has security implications. + +### Automatic Hot-Reload +All configuration files are monitored for changes. When you edit any config file, the system automatically detects the change and reloads the configuration within ~1 second. No manual reload actions or restart required. + +Monitored config files: +- `app/config/settings.json` - Settings (API keys, model config, OAuth credentials) +- `app/config/mcp_config.json` - MCP server connections +- `app/config/skills_config.json` - Skill configurations +- `app/config/external_comms_config.json` - Communication platform integrations + +### 1. MCP - Install New Tools +Config file: `app/config/mcp_config.json` + +When you lack a capability (e.g., cannot access a service, need a specific tool): +1. Use `read_file` to check existing MCP servers in `app/config/mcp_config.json` +2. Use `web_search` to find MCP servers: search " MCP server" or "modelcontextprotocol " +3. Use `stream_edit` to add new server entry to the `mcp_servers` array in `app/config/mcp_config.json` +4. Set `"enabled": true` to activate the server +5. The system will automatically detect the change and connect to the new server + +MCP server entry format: +```json +{ + "name": "server-name", + "description": "What this server does", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@org/server-package"], + "env": {"API_KEY": ""}, + "enabled": true +} +``` + +Common patterns: +- NPX packages: `"command": "npx", "args": ["-y", "@modelcontextprotocol/server-name"]` +- Python servers: `"command": "uv", "args": ["run", "--directory", "/path/to/server", "main.py"]` +- HTTP/SSE servers: `"transport": "sse", "url": "http://localhost:3000/mcp"` + +### 2. Skill - Install Workflows and Instructions +Config file: `app/config/skills_config.json` +Skills directory: `skills/` + +When you need specialized workflows or domain knowledge: +1. Use `read_file` to check `app/config/skills_config.json` for existing skills +2. Use `web_search` to find skills: search "SKILL.md " or " agent skill github" +3. Use `run_shell` to clone the skill repository into the `skills/` directory: + `git clone https://github.com/user/skill-repo skills/skill-name` +4. Use `stream_edit` to add the skill name to `enabled_skills` array in `app/config/skills_config.json` +5. The system will automatically detect the change and load the new skill + +### 3. App - Configure Integrations +Config file: `app/config/external_comms_config.json` + +When you need to connect to communication platforms: +1. Use `read_file` to check current config in `app/config/external_comms_config.json` +2. Use `stream_edit` to update the platform configuration: + - Set required credentials (bot_token, api_key, phone_number, etc.) + - Set `"enabled": true` to activate +3. The system will automatically detect the change and start/stop platform connections + +Supported platforms: +- Telegram: bot mode (bot_token) or user mode (api_id, api_hash, phone_number) +- WhatsApp: web mode (session_id) or API mode (phone_number_id, access_token) + +### 4. Model & API Keys - Configure Providers +Config file: `app/config/settings.json` + +When you need different model capabilities or need to set API keys: +1. Use `read_file` to check current settings in `app/config/settings.json` +2. If the target model has no API key, you MUST ask the user for one. Without a valid API key, all LLM requests will fail. +3. Use `stream_edit` to update model configuration and/or API keys: +```json +{ + "model": { + "llm_provider": "anthropic", + "vlm_provider": "anthropic", + "llm_model": "claude-sonnet-4-20250514", + "vlm_model": "claude-sonnet-4-20250514" + }, + "api_keys": { + "openai": "sk-...", + "anthropic": "sk-ant-...", + "google": "...", + "byteplus": "..." + } +} +``` +4. The system will automatically detect the change and update settings (model changes take effect in new tasks) + +Available providers: openai, anthropic, gemini, byteplus, remote (Ollama) + +### 5. Memory - Learn and Remember +When you learn something useful (user preferences, project context, solutions to problems): +- Use `memory_search` action to check if relevant memory already exists +- Store important learnings in MEMORY.md via memory processing actions +- Use `read_file` to read USER.md and AGENT.md to understand context before tasks +- Use `stream_edit` to update USER.md with user preferences you discover +- Use `stream_edit` to update AGENT.md with operational improvements + ## Proactive Behavior You activate on schedules (hourly/daily/weekly/monthly). diff --git a/app/data/agent_file_system_template/USER.md b/app/data/agent_file_system_template/USER.md index d072af8a..74b1af08 100644 --- a/app/data/agent_file_system_template/USER.md +++ b/app/data/agent_file_system_template/USER.md @@ -7,6 +7,7 @@ - **Job:** (Ask the users for info) ## Communication Preferences +- **Language:** en - **Preferred Tone:** (Ask the users for info) - **Response Style:** (Ask the users for info) diff --git a/app/external_comms/integration_discovery.py b/app/external_comms/integration_discovery.py index 14ac687c..8c48a671 100644 --- a/app/external_comms/integration_discovery.py +++ b/app/external_comms/integration_discovery.py @@ -20,6 +20,9 @@ "whatsapp_business": "whatsapp", "discord": "discord", "slack": "slack", + "jira": "jira", + "github": "github", + "twitter": "twitter", } # Maps action sets to their primary send actions for conversation mode @@ -30,6 +33,9 @@ "whatsapp": ["send_whatsapp_web_text_message"], "discord": ["send_discord_message", "send_discord_dm"], "slack": ["send_slack_message"], + "jira": ["add_jira_comment", "create_jira_issue"], + "github": ["add_github_comment", "create_github_issue"], + "twitter": ["post_tweet", "reply_to_tweet"], } diff --git a/app/external_comms/integration_settings.py b/app/external_comms/integration_settings.py index dcce96ff..a3d18ee8 100644 --- a/app/external_comms/integration_settings.py +++ b/app/external_comms/integration_settings.py @@ -69,6 +69,35 @@ {"key": "phone_number_id", "label": "Phone Number ID", "placeholder": "Enter phone number ID", "password": False}, ], }, + "jira": { + "name": "Jira", + "description": "Issue tracking and project management", + "auth_type": "token", + "fields": [ + {"key": "domain", "label": "Jira Domain", "placeholder": "mycompany.atlassian.net", "password": False}, + {"key": "email", "label": "Email", "placeholder": "you@example.com", "password": False}, + {"key": "api_token", "label": "API Token", "placeholder": "Enter Jira API token", "password": True}, + ], + }, + "github": { + "name": "GitHub", + "description": "Repositories, issues, and pull requests", + "auth_type": "token", + "fields": [ + {"key": "access_token", "label": "Personal Access Token", "placeholder": "ghp_...", "password": True}, + ], + }, + "twitter": { + "name": "Twitter/X", + "description": "Tweets, mentions, and timeline", + "auth_type": "token", + "fields": [ + {"key": "api_key", "label": "Consumer Key", "placeholder": "Enter Consumer key", "password": True}, + {"key": "api_secret", "label": "Consumer Secret", "placeholder": "Enter Consumer secret", "password": True}, + {"key": "access_token", "label": "Access Token", "placeholder": "Enter access token", "password": True}, + {"key": "access_token_secret", "label": "Access Token Secret", "placeholder": "Enter access token secret", "password": True}, + ], + }, } @@ -205,6 +234,9 @@ def get_integration_accounts(integration_id: str) -> List[Dict[str, str]]: "whatsapp": ["whatsapp_web"], "telegram": ["telegram_bot", "telegram_user"], "google": ["google_workspace"], + "jira": ["jira"], + "github": ["github"], + "twitter": ["twitter"], } @@ -272,6 +304,29 @@ async def connect_integration_token(integration_id: str, credentials: Dict[str, return False, "Access token and phone number ID are required" args = [access_token, phone_number_id] + elif integration_id == "jira": + domain = credentials.get("domain", "") + email = credentials.get("email", "") + api_token = credentials.get("api_token", "") + if not domain or not email or not api_token: + return False, "Domain, email, and API token are required" + args = [domain, email, api_token] + + elif integration_id == "github": + access_token = credentials.get("access_token", "") + if not access_token: + return False, "Personal access token is required" + args = [access_token] + + elif integration_id == "twitter": + api_key = credentials.get("api_key", "") + api_secret = credentials.get("api_secret", "") + access_token = credentials.get("access_token", "") + access_token_secret = credentials.get("access_token_secret", "") + if not all([api_key, api_secret, access_token, access_token_secret]): + return False, "All four Twitter API credentials are required" + args = [api_key, api_secret, access_token, access_token_secret] + else: return False, f"Token-based login not supported for {integration_id}" diff --git a/app/external_comms/manager.py b/app/external_comms/manager.py index 3ce0d0dd..f54fc5a9 100644 --- a/app/external_comms/manager.py +++ b/app/external_comms/manager.py @@ -40,6 +40,9 @@ def _import_all_platforms() -> None: "app.external_comms.platforms.linkedin", "app.external_comms.platforms.outlook", "app.external_comms.platforms.google_workspace", + "app.external_comms.platforms.jira", + "app.external_comms.platforms.github", + "app.external_comms.platforms.twitter", ] import importlib for mod in platform_modules: diff --git a/app/external_comms/platforms/github.py b/app/external_comms/platforms/github.py new file mode 100644 index 00000000..bcd760ac --- /dev/null +++ b/app/external_comms/platforms/github.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- +"""GitHub REST API client — direct HTTP via httpx. + +Supports personal access token (PAT) authentication. +Listening is implemented via polling for notifications and +events on watched repositories. An optional **watch_tag** lets +users restrict triggers to issue/PR comments mentioning a tag +(e.g. ``@craftbot``). +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +import httpx + +from app.external_comms.base import BasePlatformClient, PlatformMessage, MessageCallback +from app.external_comms.credentials import has_credential, load_credential, save_credential, remove_credential +from app.external_comms.registry import register_client + +try: + from app.logger import logger +except Exception: + logger = logging.getLogger(__name__) + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + +GITHUB_API = "https://api.github.com" +CREDENTIAL_FILE = "github.json" + +POLL_INTERVAL = 15 # seconds between polls +RETRY_DELAY = 30 # seconds to wait after a poll error + + +@dataclass +class GitHubCredential: + access_token: str = "" + username: str = "" + # Listener settings + watch_repos: List[str] = field(default_factory=list) # e.g. ["owner/repo"] + watch_tag: str = "" # e.g. "@craftbot" — only trigger on comments containing this + + +@register_client +class GitHubClient(BasePlatformClient): + """GitHub platform client with notification polling.""" + + PLATFORM_ID = "github" + + def __init__(self) -> None: + super().__init__() + self._cred: Optional[GitHubCredential] = None + self._poll_task: Optional[asyncio.Task] = None + self._last_modified: Optional[str] = None # If-Modified-Since header + self._seen_ids: set = set() + self._catchup_done: bool = False + + # ------------------------------------------------------------------ + # Credential helpers + # ------------------------------------------------------------------ + + def has_credentials(self) -> bool: + return has_credential(CREDENTIAL_FILE) + + def _load(self) -> GitHubCredential: + if self._cred is None: + self._cred = load_credential(CREDENTIAL_FILE, GitHubCredential) + if self._cred is None: + raise RuntimeError("No GitHub credentials. Use /github login first.") + return self._cred + + def _headers(self) -> Dict[str, str]: + cred = self._load() + return { + "Authorization": f"Bearer {cred.access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + # ------------------------------------------------------------------ + # BasePlatformClient interface + # ------------------------------------------------------------------ + + async def connect(self) -> None: + self._load() + self._connected = True + + async def send_message(self, recipient: str, text: str, **kwargs) -> Dict[str, Any]: + """Create a comment on an issue/PR. + + Args: + recipient: "{owner}/{repo}#{number}" e.g. "octocat/hello-world#1" + text: Comment body (markdown). + """ + try: + # Parse "owner/repo#number" + repo_part, number = recipient.rsplit("#", 1) + return await self.create_comment(repo_part.strip(), int(number), text) + except (ValueError, IndexError): + return {"error": f"Invalid recipient format. Use 'owner/repo#number', got: {recipient}"} + + # ------------------------------------------------------------------ + # Watch tag / repos configuration + # ------------------------------------------------------------------ + + def get_watch_tag(self) -> str: + return self._load().watch_tag + + def set_watch_tag(self, tag: str) -> None: + cred = self._load() + cred.watch_tag = tag.strip() + save_credential(CREDENTIAL_FILE, cred) + self._cred = cred + logger.info(f"[GITHUB] Watch tag set to: {cred.watch_tag or '(disabled)'}") + + def get_watch_repos(self) -> List[str]: + return list(self._load().watch_repos) + + def set_watch_repos(self, repos: List[str]) -> None: + cred = self._load() + cred.watch_repos = [r.strip() for r in repos if r.strip()] + save_credential(CREDENTIAL_FILE, cred) + self._cred = cred + logger.info(f"[GITHUB] Watch repos set to: {cred.watch_repos or '(all)'}") + + # ------------------------------------------------------------------ + # Listening (notification polling) + # ------------------------------------------------------------------ + + @property + def supports_listening(self) -> bool: + return True + + async def start_listening(self, callback: MessageCallback) -> None: + if self._listening: + return + + self._message_callback = callback + self._load() + + # Verify token + me = await self.get_authenticated_user() + if "error" in me: + raise RuntimeError(f"Invalid GitHub token: {me.get('error')}") + + username = me.get("result", {}).get("login", "unknown") + logger.info(f"[GITHUB] Authenticated as: {username}") + + # Save username + cred = self._load() + if cred.username != username: + cred.username = username + save_credential(CREDENTIAL_FILE, cred) + self._cred = cred + + # Catchup: mark current time so we skip old notifications + self._last_modified = datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT") + self._catchup_done = True + self._listening = True + self._poll_task = asyncio.create_task(self._poll_loop()) + + tag_info = cred.watch_tag or "(disabled — all events)" + repos_info = ", ".join(cred.watch_repos) if cred.watch_repos else "(all repos)" + logger.info(f"[GITHUB] Poller started — tag: {tag_info} | repos: {repos_info}") + + async def stop_listening(self) -> None: + if not self._listening: + return + self._listening = False + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + logger.info("[GITHUB] Poller stopped") + + async def _poll_loop(self) -> None: + while self._listening: + try: + await self._check_notifications() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[GITHUB] Poll error: {e}") + await asyncio.sleep(RETRY_DELAY) + continue + await asyncio.sleep(POLL_INTERVAL) + + async def _check_notifications(self) -> None: + headers = self._headers() + if self._last_modified: + headers["If-Modified-Since"] = self._last_modified + + cred = self._load() + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{GITHUB_API}/notifications", + headers=headers, + params={"all": "false", "participating": "true"}, + timeout=30, + ) + + if resp.status_code == 304: + return # No new notifications + if resp.status_code == 401: + logger.warning("[GITHUB] Authentication expired (401)") + return + if resp.status_code != 200: + logger.warning(f"[GITHUB] Notifications API error: {resp.status_code}") + return + + # Update Last-Modified for next poll + lm = resp.headers.get("Last-Modified") + if lm: + self._last_modified = lm + + notifications = resp.json() + + for notif in notifications: + notif_id = notif.get("id", "") + if notif_id in self._seen_ids: + continue + self._seen_ids.add(notif_id) + + # Filter by watched repos + repo_full = notif.get("repository", {}).get("full_name", "") + if cred.watch_repos and repo_full not in cred.watch_repos: + continue + + await self._dispatch_notification(client, notif) + + # Cap seen set + if len(self._seen_ids) > 500: + self._seen_ids = set(list(self._seen_ids)[-200:]) + + async def _dispatch_notification(self, client: httpx.AsyncClient, notif: Dict[str, Any]) -> None: + if not self._message_callback: + return + + cred = self._load() + reason = notif.get("reason", "") + subject = notif.get("subject", {}) + subject_type = subject.get("type", "") # Issue, PullRequest, etc. + subject_title = subject.get("title", "") + subject_url = subject.get("url", "") + repo = notif.get("repository", {}) + repo_full = repo.get("full_name", "") + + # Fetch the latest comment if there's a comment URL + latest_comment_url = subject.get("latest_comment_url", "") + comment_body = "" + comment_author = "" + if latest_comment_url: + try: + cr = await client.get(latest_comment_url, headers=self._headers(), timeout=15) + if cr.status_code == 200: + comment_data = cr.json() + comment_body = comment_data.get("body", "") + comment_author = comment_data.get("user", {}).get("login", "") + except Exception: + pass + + # Watch tag filtering + watch_tag = cred.watch_tag + if watch_tag: + if not comment_body or watch_tag.lower() not in comment_body.lower(): + return # Skip — no matching tag in comment + + # Extract instruction after the tag + tag_lower = watch_tag.lower() + idx = comment_body.lower().find(tag_lower) + instruction = comment_body[idx + len(watch_tag):].strip() if idx >= 0 else comment_body + + text_parts = [ + f"[{repo_full}] {subject_type}: {subject_title}", + f"Comment by @{comment_author}: {instruction}", + ] + + platform_msg = PlatformMessage( + platform="github", + sender_id=comment_author, + sender_name=comment_author, + text="\n".join(text_parts), + channel_id=repo_full, + channel_name=repo_full, + message_id=notif.get("id", ""), + timestamp=datetime.now(timezone.utc), + raw={ + "notification": notif, + "trigger": "comment_tag", + "tag": watch_tag, + "instruction": instruction, + "comment_body": comment_body, + "comment_author": comment_author, + }, + ) + + await self._message_callback(platform_msg) + logger.info(f"[GITHUB] Tag '{watch_tag}' matched in {repo_full} by @{comment_author}: {instruction[:80]}...") + return + + # No watch tag — dispatch all notifications + text_parts = [ + f"[{repo_full}] {subject_type}: {subject_title}", + f"Reason: {reason}", + ] + if comment_body: + text_parts.append(f"Comment by @{comment_author}: {comment_body[:300]}") + + platform_msg = PlatformMessage( + platform="github", + sender_id=comment_author or "", + sender_name=comment_author or reason, + text="\n".join(text_parts), + channel_id=repo_full, + channel_name=repo_full, + message_id=notif.get("id", ""), + timestamp=datetime.now(timezone.utc), + raw=notif, + ) + + await self._message_callback(platform_msg) + + # ------------------------------------------------------------------ + # GitHub REST API methods + # ------------------------------------------------------------------ + + async def get_authenticated_user(self) -> Dict[str, Any]: + """Get the authenticated user's info.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get(f"{GITHUB_API}/user", headers=self._headers(), timeout=15) + if resp.status_code == 200: + data = resp.json() + return {"ok": True, "result": {"login": data.get("login"), "name": data.get("name"), "id": data.get("id")}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def list_repos(self, per_page: int = 30, sort: str = "updated") -> Dict[str, Any]: + """List repositories for the authenticated user.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{GITHUB_API}/user/repos", + headers=self._headers(), + params={"per_page": per_page, "sort": sort}, + timeout=15, + ) + if resp.status_code == 200: + repos = [{"full_name": r.get("full_name"), "name": r.get("name"), "private": r.get("private"), "description": r.get("description", "")} for r in resp.json()] + return {"ok": True, "result": {"repos": repos}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def get_repo(self, owner_repo: str) -> Dict[str, Any]: + """Get repository info.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get(f"{GITHUB_API}/repos/{owner_repo}", headers=self._headers(), timeout=15) + if resp.status_code == 200: + return {"ok": True, "result": resp.json()} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def list_issues(self, owner_repo: str, state: str = "open", per_page: int = 30) -> Dict[str, Any]: + """List issues for a repository.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{GITHUB_API}/repos/{owner_repo}/issues", + headers=self._headers(), + params={"state": state, "per_page": per_page}, + timeout=15, + ) + if resp.status_code == 200: + issues = [ + { + "number": i.get("number"), + "title": i.get("title"), + "state": i.get("state"), + "user": i.get("user", {}).get("login", ""), + "labels": [l.get("name") for l in i.get("labels", [])], + "assignees": [a.get("login") for a in i.get("assignees", [])], + "created_at": i.get("created_at"), + "updated_at": i.get("updated_at"), + "is_pr": "pull_request" in i, + } + for i in resp.json() + ] + return {"ok": True, "result": {"issues": issues}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def get_issue(self, owner_repo: str, number: int) -> Dict[str, Any]: + """Get a specific issue or PR.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get(f"{GITHUB_API}/repos/{owner_repo}/issues/{number}", headers=self._headers(), timeout=15) + if resp.status_code == 200: + return {"ok": True, "result": resp.json()} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def create_issue(self, owner_repo: str, title: str, body: str = "", labels: Optional[List[str]] = None, assignees: Optional[List[str]] = None) -> Dict[str, Any]: + """Create a new issue.""" + payload: Dict[str, Any] = {"title": title} + if body: + payload["body"] = body + if labels: + payload["labels"] = labels + if assignees: + payload["assignees"] = assignees + try: + async with httpx.AsyncClient() as client: + resp = await client.post(f"{GITHUB_API}/repos/{owner_repo}/issues", headers=self._headers(), json=payload, timeout=15) + if resp.status_code in (200, 201): + data = resp.json() + return {"ok": True, "result": {"number": data.get("number"), "html_url": data.get("html_url"), "title": data.get("title")}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def create_comment(self, owner_repo: str, number: int, body: str) -> Dict[str, Any]: + """Create a comment on an issue or PR.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{GITHUB_API}/repos/{owner_repo}/issues/{number}/comments", + headers=self._headers(), + json={"body": body}, + timeout=15, + ) + if resp.status_code in (200, 201): + data = resp.json() + return {"ok": True, "result": {"id": data.get("id"), "html_url": data.get("html_url")}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def list_pull_requests(self, owner_repo: str, state: str = "open", per_page: int = 30) -> Dict[str, Any]: + """List pull requests for a repository.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{GITHUB_API}/repos/{owner_repo}/pulls", + headers=self._headers(), + params={"state": state, "per_page": per_page}, + timeout=15, + ) + if resp.status_code == 200: + prs = [ + { + "number": p.get("number"), + "title": p.get("title"), + "state": p.get("state"), + "user": p.get("user", {}).get("login", ""), + "head": p.get("head", {}).get("ref", ""), + "base": p.get("base", {}).get("ref", ""), + "draft": p.get("draft", False), + "created_at": p.get("created_at"), + } + for p in resp.json() + ] + return {"ok": True, "result": {"pull_requests": prs}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def search_issues(self, query: str, per_page: int = 20) -> Dict[str, Any]: + """Search issues and PRs.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{GITHUB_API}/search/issues", + headers=self._headers(), + params={"q": query, "per_page": per_page}, + timeout=30, + ) + if resp.status_code == 200: + data = resp.json() + items = [ + { + "number": i.get("number"), + "title": i.get("title"), + "state": i.get("state"), + "repo": i.get("repository_url", "").split("/repos/")[-1] if i.get("repository_url") else "", + "user": i.get("user", {}).get("login", ""), + "html_url": i.get("html_url"), + } + for i in data.get("items", []) + ] + return {"ok": True, "result": {"total_count": data.get("total_count", 0), "items": items}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def add_labels(self, owner_repo: str, number: int, labels: List[str]) -> Dict[str, Any]: + """Add labels to an issue/PR.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{GITHUB_API}/repos/{owner_repo}/issues/{number}/labels", + headers=self._headers(), + json={"labels": labels}, + timeout=15, + ) + if resp.status_code == 200: + return {"ok": True, "result": {"labels_added": labels}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def close_issue(self, owner_repo: str, number: int) -> Dict[str, Any]: + """Close an issue.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.patch( + f"{GITHUB_API}/repos/{owner_repo}/issues/{number}", + headers=self._headers(), + json={"state": "closed"}, + timeout=15, + ) + if resp.status_code == 200: + return {"ok": True, "result": {"closed": True, "number": number}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} diff --git a/app/external_comms/platforms/jira.py b/app/external_comms/platforms/jira.py new file mode 100644 index 00000000..ac1cffef --- /dev/null +++ b/app/external_comms/platforms/jira.py @@ -0,0 +1,1007 @@ +# -*- coding: utf-8 -*- +"""Jira Cloud REST API client — direct HTTP via httpx. + +Supports two auth modes: +- **API Token**: email + API token (Atlassian account). +- **OAuth 2.0**: access_token + cloud_id (from CraftOS backend OAuth flow). + +Listening is implemented via polling the Jira search API (JQL) for +recently-updated issues. An optional **watch_labels** filter lets +users restrict events to issues carrying specific labels. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Union + +import httpx + +from app.external_comms.base import BasePlatformClient, PlatformMessage, MessageCallback +from app.external_comms.credentials import has_credential, load_credential, save_credential, remove_credential +from app.external_comms.registry import register_client + +try: + from app.logger import logger +except Exception: + logger = logging.getLogger(__name__) + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + +JIRA_CLOUD_API = "https://api.atlassian.com/ex/jira" +CREDENTIAL_FILE = "jira.json" + +POLL_INTERVAL = 10 # seconds between polls +RETRY_DELAY = 15 # seconds to wait after a poll error + + +@dataclass +class JiraCredential: + # API-token auth + domain: str = "" # e.g. "mycompany.atlassian.net" + email: str = "" + api_token: str = "" + # OAuth auth (from CraftOS backend) + cloud_id: str = "" + access_token: str = "" + refresh_token: str = "" + token_expiry: float = 0.0 + site_url: str = "" + # Listener settings + watch_labels: List[str] = field(default_factory=list) + watch_tag: str = "" # e.g. "@craftbot" — only trigger on comments containing this tag + + +@register_client +class JiraClient(BasePlatformClient): + """Jira Cloud platform client with JQL-based polling listener.""" + + PLATFORM_ID = "jira" + + def __init__(self) -> None: + super().__init__() + self._cred: Optional[JiraCredential] = None + self._poll_task: Optional[asyncio.Task] = None + self._last_poll_time: Optional[str] = None # ISO 8601 + self._seen_issue_keys: set = set() + self._catchup_done: bool = False + + # ------------------------------------------------------------------ + # Credential helpers + # ------------------------------------------------------------------ + + def has_credentials(self) -> bool: + return has_credential(CREDENTIAL_FILE) + + def _load(self) -> JiraCredential: + if self._cred is None: + self._cred = load_credential(CREDENTIAL_FILE, JiraCredential) + if self._cred is None: + raise RuntimeError("No Jira credentials. Use /jira login first.") + return self._cred + + def _is_oauth(self) -> bool: + cred = self._load() + return bool(cred.cloud_id and cred.access_token) + + def _base_url(self) -> str: + cred = self._load() + if cred.cloud_id: + return f"{JIRA_CLOUD_API}/{cred.cloud_id}/rest/api/3" + if cred.domain: + domain = cred.domain.rstrip("/") + if not domain.startswith("http"): + domain = f"https://{domain}" + return f"{domain}/rest/api/3" + raise RuntimeError("No Jira domain or cloud_id configured.") + + def _headers(self) -> Dict[str, str]: + cred = self._load() + headers: Dict[str, str] = { + "Accept": "application/json", + "Content-Type": "application/json", + } + if cred.cloud_id and cred.access_token: + headers["Authorization"] = f"Bearer {cred.access_token}" + elif cred.email and cred.api_token: + import base64 + raw = f"{cred.email}:{cred.api_token}" + encoded = base64.b64encode(raw.encode()).decode() + headers["Authorization"] = f"Basic {encoded}" + else: + raise RuntimeError("Incomplete Jira credentials (need email+api_token or cloud_id+access_token).") + return headers + + # ------------------------------------------------------------------ + # BasePlatformClient interface + # ------------------------------------------------------------------ + + async def connect(self) -> None: + self._load() + self._connected = True + + async def send_message(self, recipient: str, text: str, **kwargs) -> Dict[str, Any]: + """Send a comment to a Jira issue. + + Args: + recipient: Issue key (e.g. "PROJ-123"). + text: Comment body text. + """ + return await self.add_comment(recipient, text) + + # ------------------------------------------------------------------ + # Watch-label configuration + # ------------------------------------------------------------------ + + def get_watch_labels(self) -> List[str]: + """Return the list of labels the listener filters on.""" + cred = self._load() + return list(cred.watch_labels) + + def set_watch_labels(self, labels: List[str]) -> None: + """Set the labels to filter on when listening. + + Pass an empty list to watch all issues (no filtering). + """ + cred = self._load() + cred.watch_labels = [lbl.strip() for lbl in labels if lbl.strip()] + save_credential(CREDENTIAL_FILE, cred) + self._cred = cred + logger.info(f"[JIRA] Watch labels set to: {cred.watch_labels or '(all issues)'}") + + def add_watch_label(self, label: str) -> None: + """Add a single label to the watch list.""" + cred = self._load() + label = label.strip() + if label and label not in cred.watch_labels: + cred.watch_labels.append(label) + save_credential(CREDENTIAL_FILE, cred) + self._cred = cred + logger.info(f"[JIRA] Added watch label: {label}") + + def remove_watch_label(self, label: str) -> None: + """Remove a single label from the watch list.""" + cred = self._load() + label = label.strip() + if label in cred.watch_labels: + cred.watch_labels.remove(label) + save_credential(CREDENTIAL_FILE, cred) + self._cred = cred + logger.info(f"[JIRA] Removed watch label: {label}") + + # -- Watch tag (comment mention filter) ---------------------------- + + def get_watch_tag(self) -> str: + """Return the tag the listener filters comments on (e.g. '@craftbot').""" + cred = self._load() + return cred.watch_tag + + def set_watch_tag(self, tag: str) -> None: + """Set the mention tag to watch for in comments. + + Only comments containing this tag will trigger events. + Pass an empty string to trigger on all issue updates (no comment filtering). + """ + cred = self._load() + cred.watch_tag = tag.strip() + save_credential(CREDENTIAL_FILE, cred) + self._cred = cred + logger.info(f"[JIRA] Watch tag set to: {cred.watch_tag or '(disabled — all updates)'}") + + # ------------------------------------------------------------------ + # Listening (JQL polling) + # ------------------------------------------------------------------ + + @property + def supports_listening(self) -> bool: + return True + + async def start_listening(self, callback: MessageCallback) -> None: + if self._listening: + return + + self._message_callback = callback + self._load() + + # Verify credentials + me = await self.get_myself() + if "error" in me: + raise RuntimeError(f"Invalid Jira credentials: {me.get('error')}") + + display = me.get("result", {}).get("displayName", "unknown") + logger.info(f"[JIRA] Authenticated as: {display}") + + # Catchup: set last poll time to now + self._last_poll_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M") + self._catchup_done = True + self._listening = True + self._poll_task = asyncio.create_task(self._poll_loop()) + + cred = self._load() + labels_info = ", ".join(cred.watch_labels) if cred.watch_labels else "(all)" + tag_info = cred.watch_tag or "(disabled — all updates)" + logger.info(f"[JIRA] Poller started — labels: {labels_info} | tag: {tag_info}") + + async def stop_listening(self) -> None: + if not self._listening: + return + self._listening = False + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + logger.info("[JIRA] Poller stopped") + + async def _poll_loop(self) -> None: + while self._listening: + try: + await self._check_updates() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[JIRA] Poll error: {e}") + await asyncio.sleep(RETRY_DELAY) + continue + await asyncio.sleep(POLL_INTERVAL) + + async def _check_updates(self) -> None: + if not self._last_poll_time: + return + + cred = self._load() + + # Build JQL + jql_parts = [f'updated >= "{self._last_poll_time}"'] + if cred.watch_labels: + label_clauses = " OR ".join(f'labels = "{lbl}"' for lbl in cred.watch_labels) + jql_parts.append(f"({label_clauses})") + jql = " AND ".join(jql_parts) + jql += " ORDER BY updated ASC" + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url()}/search/jql", + headers=self._headers(), + json={ + "jql": jql, + "maxResults": 50, + "fields": ["summary", "status", "assignee", "reporter", "labels", "updated", "comment", "issuetype", "priority", "project"], + }, + timeout=30, + ) + + if resp.status_code == 401: + logger.warning("[JIRA] Authentication expired (401)") + return + if resp.status_code != 200: + logger.warning(f"[JIRA] Search API error: {resp.status_code} — {resp.text[:300]}") + return + + data = resp.json() + issues = data.get("issues", []) + + for issue in issues: + issue_key = issue.get("key", "") + updated = issue.get("fields", {}).get("updated", "") + + # Build a dedup key from issue key + updated timestamp + dedup_key = f"{issue_key}:{updated}" + if dedup_key in self._seen_issue_keys: + continue + self._seen_issue_keys.add(dedup_key) + + await self._dispatch_issue(issue) + + # Update poll time + self._last_poll_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M") + + # Cap seen set + if len(self._seen_issue_keys) > 500: + self._seen_issue_keys = set(list(self._seen_issue_keys)[-200:]) + + async def _dispatch_issue(self, issue: Dict[str, Any]) -> None: + if not self._message_callback: + return + + cred = self._load() + fields_data = issue.get("fields", {}) + issue_key = issue.get("key", "") + summary = fields_data.get("summary", "") + status_name = (fields_data.get("status") or {}).get("name", "") + issue_type = (fields_data.get("issuetype") or {}).get("name", "") + priority = (fields_data.get("priority") or {}).get("name", "") + project_key = (fields_data.get("project") or {}).get("key", "") + labels = fields_data.get("labels", []) + + assignee = fields_data.get("assignee") or {} + assignee_name = assignee.get("displayName", "Unassigned") + + reporter = fields_data.get("reporter") or {} + reporter_name = reporter.get("displayName", "Unknown") + + # Extract comments + comments = (fields_data.get("comment") or {}).get("comments", []) + + # --- Watch tag filtering --- + # If a watch_tag is set, only dispatch when a comment contains the tag. + # The triggering comment text (after the tag) becomes the message. + watch_tag = cred.watch_tag + if watch_tag: + matching_comment = None + tag_lower = watch_tag.lower() + # Scan comments newest-first for one containing the tag + for comment in reversed(comments): + comment_body = _extract_adf_text(comment.get("body", {})) + if tag_lower in comment_body.lower(): + # Dedup: use comment ID so we don't re-trigger on same comment + comment_id = comment.get("id", "") + comment_dedup = f"{issue_key}:comment:{comment_id}" + if comment_dedup in self._seen_issue_keys: + continue + self._seen_issue_keys.add(comment_dedup) + matching_comment = comment + break + + if matching_comment is None: + # No comment with the tag — skip this issue entirely + return + + # Build message from the tagged comment + comment_author = (matching_comment.get("author") or {}).get("displayName", "Unknown") + comment_author_id = (matching_comment.get("author") or {}).get("accountId", "") + comment_body = _extract_adf_text(matching_comment.get("body", {})) + + # Strip the tag from the comment to get the instruction + idx = comment_body.lower().find(tag_lower) + if idx >= 0: + instruction = comment_body[idx + len(watch_tag):].strip() + else: + instruction = comment_body + + text_parts = [ + f"[{issue_key}] {summary}", + f"Status: {status_name} | Assignee: {assignee_name}", + f"Comment by {comment_author}: {instruction or comment_body}", + ] + + timestamp = None + created_str = matching_comment.get("created", "") + if created_str: + try: + timestamp = datetime.fromisoformat(created_str.replace("Z", "+00:00")) + except Exception: + pass + + platform_msg = PlatformMessage( + platform="jira", + sender_id=comment_author_id, + sender_name=comment_author, + text="\n".join(text_parts), + channel_id=project_key, + channel_name=f"{project_key} ({issue_type})", + message_id=f"{issue_key}:{matching_comment.get('id', '')}", + timestamp=timestamp, + raw={ + "issue": issue, + "trigger": "comment_tag", + "tag": watch_tag, + "instruction": instruction or comment_body, + "comment": matching_comment, + }, + ) + + await self._message_callback(platform_msg) + logger.info(f"[JIRA] Tag '{watch_tag}' matched in {issue_key} by {comment_author}: {instruction[:80]}...") + return + + # --- No watch tag — dispatch all updates (original behavior) --- + text_parts = [ + f"[{issue_key}] {summary}", + f"Type: {issue_type} | Priority: {priority} | Status: {status_name}", + f"Project: {project_key} | Assignee: {assignee_name}", + ] + if labels: + text_parts.append(f"Labels: {', '.join(labels)}") + + if comments: + latest_comment = comments[-1] + comment_author = (latest_comment.get("author") or {}).get("displayName", "") + comment_body = _extract_adf_text(latest_comment.get("body", {})) + if comment_body: + text_parts.append(f"Latest comment by {comment_author}: {comment_body[:200]}") + + timestamp = None + updated_str = fields_data.get("updated", "") + if updated_str: + try: + timestamp = datetime.fromisoformat(updated_str.replace("Z", "+00:00")) + except Exception: + pass + + platform_msg = PlatformMessage( + platform="jira", + sender_id=reporter.get("accountId", ""), + sender_name=reporter_name, + text="\n".join(text_parts), + channel_id=project_key, + channel_name=f"{project_key} ({issue_type})", + message_id=issue_key, + timestamp=timestamp, + raw=issue, + ) + + await self._message_callback(platform_msg) + + # ------------------------------------------------------------------ + # Jira REST API methods + # ------------------------------------------------------------------ + + async def get_myself(self) -> Dict[str, Any]: + """Get the authenticated user's info.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self._base_url()}/myself", + headers=self._headers(), + timeout=15, + ) + if resp.status_code == 200: + data = resp.json() + return { + "ok": True, + "result": { + "accountId": data.get("accountId"), + "displayName": data.get("displayName"), + "emailAddress": data.get("emailAddress", ""), + "active": data.get("active", True), + }, + } + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def search_issues( + self, + jql: str, + max_results: int = 50, + fields_list: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Search for issues using JQL. + + Args: + jql: JQL query string. + max_results: Maximum number of results (max 100). + fields_list: List of fields to return. + + Returns: + API response with matching issues or error. + """ + payload: Dict[str, Any] = { + "jql": jql, + "maxResults": min(max_results, 100), + } + if fields_list: + payload["fields"] = fields_list + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url()}/search/jql", + headers=self._headers(), + json=payload, + timeout=30, + ) + if resp.status_code == 200: + data = resp.json() + return { + "ok": True, + "result": { + "total": data.get("total", 0), + "issues": data.get("issues", []), + }, + } + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def get_issue( + self, + issue_key: str, + fields_list: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Get a single issue by key. + + Args: + issue_key: Issue key (e.g. "PROJ-123"). + fields_list: Optional list of fields to return. + + Returns: + API response with issue data or error. + """ + params: Dict[str, Any] = {} + if fields_list: + params["fields"] = ",".join(fields_list) + + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self._base_url()}/issue/{issue_key}", + headers=self._headers(), + params=params, + timeout=15, + ) + if resp.status_code == 200: + return {"ok": True, "result": resp.json()} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def create_issue( + self, + project_key: str, + summary: str, + issue_type: str = "Task", + description: Optional[str] = None, + assignee_id: Optional[str] = None, + labels: Optional[List[str]] = None, + priority: Optional[str] = None, + extra_fields: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Create a new issue. + + Args: + project_key: Project key (e.g. "PROJ"). + summary: Issue summary/title. + issue_type: Issue type name (e.g. "Task", "Bug", "Story"). + description: Optional plain-text description (converted to ADF). + assignee_id: Optional Atlassian account ID. + labels: Optional list of label strings. + priority: Optional priority name (e.g. "High"). + extra_fields: Optional additional fields dict. + + Returns: + API response with created issue key/id or error. + """ + fields_payload: Dict[str, Any] = { + "project": {"key": project_key}, + "summary": summary, + "issuetype": {"name": issue_type}, + } + + if description: + fields_payload["description"] = _text_to_adf(description) + if assignee_id: + fields_payload["assignee"] = {"accountId": assignee_id} + if labels: + fields_payload["labels"] = labels + if priority: + fields_payload["priority"] = {"name": priority} + if extra_fields: + fields_payload.update(extra_fields) + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url()}/issue", + headers=self._headers(), + json={"fields": fields_payload}, + timeout=15, + ) + if resp.status_code in (200, 201): + data = resp.json() + return { + "ok": True, + "result": { + "id": data.get("id"), + "key": data.get("key"), + "self": data.get("self"), + }, + } + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def update_issue( + self, + issue_key: str, + fields_update: Dict[str, Any], + ) -> Dict[str, Any]: + """Update an existing issue's fields. + + Args: + issue_key: Issue key (e.g. "PROJ-123"). + fields_update: Dict of field names to new values. + + Returns: + API response or error. + """ + try: + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{self._base_url()}/issue/{issue_key}", + headers=self._headers(), + json={"fields": fields_update}, + timeout=15, + ) + if resp.status_code == 204: + return {"ok": True, "result": {"updated": True, "key": issue_key}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def add_comment( + self, + issue_key: str, + body: str, + ) -> Dict[str, Any]: + """Add a comment to an issue. + + Args: + issue_key: Issue key (e.g. "PROJ-123"). + body: Comment body text (converted to ADF). + + Returns: + API response with comment details or error. + """ + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url()}/issue/{issue_key}/comment", + headers=self._headers(), + json={"body": _text_to_adf(body)}, + timeout=15, + ) + if resp.status_code in (200, 201): + data = resp.json() + return { + "ok": True, + "result": { + "id": data.get("id"), + "created": data.get("created"), + "author": (data.get("author") or {}).get("displayName", ""), + }, + } + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def get_transitions(self, issue_key: str) -> Dict[str, Any]: + """Get available status transitions for an issue. + + Args: + issue_key: Issue key (e.g. "PROJ-123"). + + Returns: + API response with list of transitions or error. + """ + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self._base_url()}/issue/{issue_key}/transitions", + headers=self._headers(), + timeout=15, + ) + if resp.status_code == 200: + data = resp.json() + transitions = [ + { + "id": t.get("id"), + "name": t.get("name"), + "to": (t.get("to") or {}).get("name", ""), + } + for t in data.get("transitions", []) + ] + return {"ok": True, "result": {"transitions": transitions}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def transition_issue( + self, + issue_key: str, + transition_id: str, + comment: Optional[str] = None, + ) -> Dict[str, Any]: + """Transition an issue to a new status. + + Args: + issue_key: Issue key (e.g. "PROJ-123"). + transition_id: Transition ID (from get_transitions). + comment: Optional comment to add with the transition. + + Returns: + API response or error. + """ + payload: Dict[str, Any] = { + "transition": {"id": transition_id}, + } + if comment: + payload["update"] = { + "comment": [{"add": {"body": _text_to_adf(comment)}}], + } + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url()}/issue/{issue_key}/transitions", + headers=self._headers(), + json=payload, + timeout=15, + ) + if resp.status_code == 204: + return {"ok": True, "result": {"transitioned": True, "key": issue_key}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def assign_issue( + self, + issue_key: str, + account_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Assign an issue to a user (or unassign with None). + + Args: + issue_key: Issue key (e.g. "PROJ-123"). + account_id: Atlassian account ID, or None to unassign. + + Returns: + API response or error. + """ + try: + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{self._base_url()}/issue/{issue_key}/assignee", + headers=self._headers(), + json={"accountId": account_id}, + timeout=15, + ) + if resp.status_code == 204: + return {"ok": True, "result": {"assigned": True, "key": issue_key}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def get_projects(self, max_results: int = 50) -> Dict[str, Any]: + """Get list of accessible projects. + + Args: + max_results: Maximum number of projects to return. + + Returns: + API response with projects list or error. + """ + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self._base_url()}/project/search", + headers=self._headers(), + params={"maxResults": max_results}, + timeout=15, + ) + if resp.status_code == 200: + data = resp.json() + projects = [ + { + "id": p.get("id"), + "key": p.get("key"), + "name": p.get("name"), + "style": p.get("style", ""), + } + for p in data.get("values", []) + ] + return {"ok": True, "result": {"projects": projects}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def search_users( + self, + query: str, + max_results: int = 20, + ) -> Dict[str, Any]: + """Search for Jira users. + + Args: + query: Search string (name or email). + max_results: Maximum results to return. + + Returns: + API response with matching users or error. + """ + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self._base_url()}/user/search", + headers=self._headers(), + params={"query": query, "maxResults": max_results}, + timeout=15, + ) + if resp.status_code == 200: + users = [ + { + "accountId": u.get("accountId"), + "displayName": u.get("displayName"), + "emailAddress": u.get("emailAddress", ""), + "active": u.get("active", True), + } + for u in resp.json() + ] + return {"ok": True, "result": {"users": users}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def get_issue_comments( + self, + issue_key: str, + max_results: int = 50, + ) -> Dict[str, Any]: + """Get comments on an issue. + + Args: + issue_key: Issue key (e.g. "PROJ-123"). + max_results: Maximum comments to return. + + Returns: + API response with comments or error. + """ + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self._base_url()}/issue/{issue_key}/comment", + headers=self._headers(), + params={"maxResults": max_results, "orderBy": "-created"}, + timeout=15, + ) + if resp.status_code == 200: + data = resp.json() + comments = [ + { + "id": c.get("id"), + "author": (c.get("author") or {}).get("displayName", ""), + "body": _extract_adf_text(c.get("body", {})), + "created": c.get("created"), + "updated": c.get("updated"), + } + for c in data.get("comments", []) + ] + return {"ok": True, "result": {"comments": comments, "total": data.get("total", 0)}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def get_statuses(self, project_key: str) -> Dict[str, Any]: + """Get all statuses for a project. + + Args: + project_key: Project key (e.g. "PROJ"). + + Returns: + API response with statuses or error. + """ + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self._base_url()}/project/{project_key}/statuses", + headers=self._headers(), + timeout=15, + ) + if resp.status_code == 200: + return {"ok": True, "result": resp.json()} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def add_labels( + self, + issue_key: str, + labels: List[str], + ) -> Dict[str, Any]: + """Add labels to an issue (without removing existing ones). + + Args: + issue_key: Issue key (e.g. "PROJ-123"). + labels: List of label strings to add. + + Returns: + API response or error. + """ + update_payload = { + "update": { + "labels": [{"add": label} for label in labels], + }, + } + try: + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{self._base_url()}/issue/{issue_key}", + headers=self._headers(), + json=update_payload, + timeout=15, + ) + if resp.status_code == 204: + return {"ok": True, "result": {"labels_added": labels, "key": issue_key}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def remove_labels( + self, + issue_key: str, + labels: List[str], + ) -> Dict[str, Any]: + """Remove labels from an issue. + + Args: + issue_key: Issue key (e.g. "PROJ-123"). + labels: List of label strings to remove. + + Returns: + API response or error. + """ + update_payload = { + "update": { + "labels": [{"remove": label} for label in labels], + }, + } + try: + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{self._base_url()}/issue/{issue_key}", + headers=self._headers(), + json=update_payload, + timeout=15, + ) + if resp.status_code == 204: + return {"ok": True, "result": {"labels_removed": labels, "key": issue_key}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + +# ------------------------------------------------------------------ +# ADF (Atlassian Document Format) helpers +# ------------------------------------------------------------------ + +def _text_to_adf(text: str) -> Dict[str, Any]: + """Convert plain text to Atlassian Document Format (ADF).""" + paragraphs = text.split("\n") + content = [] + for para in paragraphs: + content.append({ + "type": "paragraph", + "content": [{"type": "text", "text": para}] if para else [], + }) + return { + "version": 1, + "type": "doc", + "content": content, + } + + +def _extract_adf_text(adf: Dict[str, Any]) -> str: + """Extract plain text from an ADF document.""" + if not isinstance(adf, dict): + return str(adf) if adf else "" + + parts: List[str] = [] + + def _walk(node: Any) -> None: + if isinstance(node, dict): + if node.get("type") == "text": + parts.append(node.get("text", "")) + for child in node.get("content", []): + _walk(child) + elif isinstance(node, list): + for item in node: + _walk(item) + + _walk(adf) + return " ".join(parts) diff --git a/app/external_comms/platforms/twitter.py b/app/external_comms/platforms/twitter.py new file mode 100644 index 00000000..9fc76143 --- /dev/null +++ b/app/external_comms/platforms/twitter.py @@ -0,0 +1,493 @@ +# -*- coding: utf-8 -*- +"""Twitter/X REST API v2 client — direct HTTP via httpx with OAuth 1.0a. + +Supports posting tweets, reading timelines, searching, managing likes/retweets, +and polling for mentions. An optional **watch_tag** lets users restrict +mention triggers to those containing a specific keyword. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import logging +import time +import urllib.parse +import secrets +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +import httpx + +from app.external_comms.base import BasePlatformClient, PlatformMessage, MessageCallback +from app.external_comms.credentials import has_credential, load_credential, save_credential, remove_credential +from app.external_comms.registry import register_client + +try: + from app.logger import logger +except Exception: + logger = logging.getLogger(__name__) + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + +TWITTER_API = "https://api.twitter.com/2" +CREDENTIAL_FILE = "twitter.json" + +POLL_INTERVAL = 30 # seconds between mention polls +RETRY_DELAY = 60 # seconds to wait after a poll error + + +@dataclass +class TwitterCredential: + api_key: str = "" + api_secret: str = "" + access_token: str = "" + access_token_secret: str = "" + user_id: str = "" + username: str = "" + # Listener settings + watch_tag: str = "" # only trigger on mentions containing this + + +def _oauth1_header( + method: str, + url: str, + params: Dict[str, str], + api_key: str, + api_secret: str, + access_token: str, + access_token_secret: str, +) -> str: + """Build an OAuth 1.0a Authorization header.""" + oauth_params = { + "oauth_consumer_key": api_key, + "oauth_nonce": secrets.token_hex(16), + "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": str(int(time.time())), + "oauth_token": access_token, + "oauth_version": "1.0", + } + + # Combine all params for signature base + all_params = {**params, **oauth_params} + sorted_params = "&".join( + f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(v, safe='')}" + for k, v in sorted(all_params.items()) + ) + + base_string = f"{method.upper()}&{urllib.parse.quote(url, safe='')}&{urllib.parse.quote(sorted_params, safe='')}" + signing_key = f"{urllib.parse.quote(api_secret, safe='')}&{urllib.parse.quote(access_token_secret, safe='')}" + + import base64 + signature = base64.b64encode( + hmac.new(signing_key.encode(), base_string.encode(), hashlib.sha1).digest() + ).decode() + + oauth_params["oauth_signature"] = signature + + header_parts = ", ".join( + f'{urllib.parse.quote(k, safe="")}="{urllib.parse.quote(v, safe="")}"' + for k, v in sorted(oauth_params.items()) + ) + return f"OAuth {header_parts}" + + +@register_client +class TwitterClient(BasePlatformClient): + """Twitter/X platform client with mention polling.""" + + PLATFORM_ID = "twitter" + + def __init__(self) -> None: + super().__init__() + self._cred: Optional[TwitterCredential] = None + self._poll_task: Optional[asyncio.Task] = None + self._since_id: Optional[str] = None + self._seen_ids: set = set() + + # ------------------------------------------------------------------ + # Credential helpers + # ------------------------------------------------------------------ + + def has_credentials(self) -> bool: + return has_credential(CREDENTIAL_FILE) + + def _load(self) -> TwitterCredential: + if self._cred is None: + self._cred = load_credential(CREDENTIAL_FILE, TwitterCredential) + if self._cred is None: + raise RuntimeError("No Twitter credentials. Use /twitter login first.") + return self._cred + + def _auth_header(self, method: str, url: str, params: Optional[Dict[str, str]] = None) -> Dict[str, str]: + cred = self._load() + return { + "Authorization": _oauth1_header( + method, url, params or {}, + cred.api_key, cred.api_secret, + cred.access_token, cred.access_token_secret, + ), + } + + def _bearer_headers(self) -> Dict[str, str]: + """Use OAuth 1.0a for all requests since we have user context.""" + cred = self._load() + return { + "Content-Type": "application/json", + } + + # ------------------------------------------------------------------ + # BasePlatformClient interface + # ------------------------------------------------------------------ + + async def connect(self) -> None: + self._load() + self._connected = True + + async def send_message(self, recipient: str, text: str, **kwargs) -> Dict[str, Any]: + """Post a tweet (recipient is ignored for tweets, or used as reply_to tweet ID).""" + return await self.post_tweet(text, reply_to=recipient if recipient else None) + + # ------------------------------------------------------------------ + # Watch tag configuration + # ------------------------------------------------------------------ + + def get_watch_tag(self) -> str: + return self._load().watch_tag + + def set_watch_tag(self, tag: str) -> None: + cred = self._load() + cred.watch_tag = tag.strip() + save_credential(CREDENTIAL_FILE, cred) + self._cred = cred + logger.info(f"[TWITTER] Watch tag set to: {cred.watch_tag or '(disabled)'}") + + # ------------------------------------------------------------------ + # Listening (mention polling) + # ------------------------------------------------------------------ + + @property + def supports_listening(self) -> bool: + return True + + async def start_listening(self, callback: MessageCallback) -> None: + if self._listening: + return + + self._message_callback = callback + cred = self._load() + + # Verify credentials + me = await self.get_me() + if "error" in me: + raise RuntimeError(f"Invalid Twitter credentials: {me.get('error')}") + + user_data = me.get("result", {}) + username = user_data.get("username", "unknown") + user_id = user_data.get("id", "") + logger.info(f"[TWITTER] Authenticated as: @{username}") + + # Save user info + if cred.username != username or cred.user_id != user_id: + cred.username = username + cred.user_id = user_id + save_credential(CREDENTIAL_FILE, cred) + self._cred = cred + + self._listening = True + self._poll_task = asyncio.create_task(self._poll_loop()) + + tag_info = cred.watch_tag or "(disabled — all mentions)" + logger.info(f"[TWITTER] Mention poller started — tag: {tag_info}") + + async def stop_listening(self) -> None: + if not self._listening: + return + self._listening = False + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + logger.info("[TWITTER] Poller stopped") + + async def _poll_loop(self) -> None: + # Initial catchup: get latest mention ID without dispatching + try: + await self._catchup() + except Exception as e: + logger.warning(f"[TWITTER] Catchup error: {e}") + + while self._listening: + try: + await self._check_mentions() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[TWITTER] Poll error: {e}") + await asyncio.sleep(RETRY_DELAY) + continue + await asyncio.sleep(POLL_INTERVAL) + + async def _catchup(self) -> None: + """Record the latest mention ID without dispatching.""" + cred = self._load() + if not cred.user_id: + return + + url = f"{TWITTER_API}/users/{cred.user_id}/mentions" + params = {"max_results": "5", "tweet.fields": "created_at,author_id,text"} + auth = self._auth_header("GET", url, params) + + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={**auth}, params=params, timeout=15) + if resp.status_code == 200: + data = resp.json() + tweets = data.get("data", []) + if tweets: + self._since_id = tweets[0].get("id") + for t in tweets: + self._seen_ids.add(t.get("id")) + logger.info(f"[TWITTER] Catchup complete — since_id: {self._since_id}") + + async def _check_mentions(self) -> None: + cred = self._load() + if not cred.user_id: + return + + url = f"{TWITTER_API}/users/{cred.user_id}/mentions" + params: Dict[str, str] = { + "max_results": "20", + "tweet.fields": "created_at,author_id,text,in_reply_to_user_id,conversation_id", + "expansions": "author_id", + "user.fields": "username,name", + } + if self._since_id: + params["since_id"] = self._since_id + + auth = self._auth_header("GET", url, params) + + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={**auth}, params=params, timeout=15) + + if resp.status_code == 429: + logger.warning("[TWITTER] Rate limited, backing off") + await asyncio.sleep(60) + return + if resp.status_code != 200: + logger.warning(f"[TWITTER] Mentions API error: {resp.status_code} — {resp.text[:200]}") + return + + data = resp.json() + tweets = data.get("data", []) + if not tweets: + return + + # Build user lookup + includes = data.get("includes", {}) + users_map = {u["id"]: u for u in includes.get("users", [])} + + # Update since_id to newest + self._since_id = tweets[0].get("id") + + for tweet in reversed(tweets): # oldest first + tweet_id = tweet.get("id", "") + if tweet_id in self._seen_ids: + continue + self._seen_ids.add(tweet_id) + + await self._dispatch_mention(tweet, users_map) + + # Cap seen set + if len(self._seen_ids) > 500: + self._seen_ids = set(list(self._seen_ids)[-200:]) + + async def _dispatch_mention(self, tweet: Dict[str, Any], users_map: Dict[str, Any]) -> None: + if not self._message_callback: + return + + cred = self._load() + text = tweet.get("text", "") + author_id = tweet.get("author_id", "") + author_info = users_map.get(author_id, {}) + author_username = author_info.get("username", "") + author_name = author_info.get("name", author_username) + + # Watch tag filtering + watch_tag = cred.watch_tag + if watch_tag: + if watch_tag.lower() not in text.lower(): + return + # Extract instruction after the tag + tag_lower = watch_tag.lower() + idx = text.lower().find(tag_lower) + instruction = text[idx + len(watch_tag):].strip() if idx >= 0 else text + else: + instruction = text + + # Remove @mentions from the start for cleaner instruction + clean_instruction = instruction + while clean_instruction.startswith("@"): + parts = clean_instruction.split(" ", 1) + clean_instruction = parts[1].strip() if len(parts) > 1 else "" + + timestamp = None + created_at = tweet.get("created_at", "") + if created_at: + try: + timestamp = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + except Exception: + pass + + platform_msg = PlatformMessage( + platform="twitter", + sender_id=author_id, + sender_name=f"@{author_username}" if author_username else author_name, + text=f"@{author_username}: {clean_instruction or text}", + channel_id=tweet.get("conversation_id", ""), + channel_name="Twitter/X", + message_id=tweet.get("id", ""), + timestamp=timestamp, + raw={ + "tweet": tweet, + "trigger": "mention" if not watch_tag else "mention_tag", + "tag": watch_tag, + "instruction": clean_instruction or text, + "author_username": author_username, + }, + ) + + await self._message_callback(platform_msg) + logger.info(f"[TWITTER] Mention from @{author_username}: {(clean_instruction or text)[:80]}...") + + # ------------------------------------------------------------------ + # Twitter API v2 methods + # ------------------------------------------------------------------ + + async def get_me(self) -> Dict[str, Any]: + """Get the authenticated user's info.""" + url = f"{TWITTER_API}/users/me" + params = {"user.fields": "id,name,username,description,public_metrics"} + auth = self._auth_header("GET", url, params) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={**auth}, params=params, timeout=15) + if resp.status_code == 200: + data = resp.json().get("data", {}) + return {"ok": True, "result": data} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def post_tweet(self, text: str, reply_to: Optional[str] = None) -> Dict[str, Any]: + """Post a tweet.""" + url = f"{TWITTER_API}/tweets" + payload: Dict[str, Any] = {"text": text} + if reply_to: + payload["reply"] = {"in_reply_to_tweet_id": reply_to} + + auth = self._auth_header("POST", url) + try: + async with httpx.AsyncClient() as client: + resp = await client.post(url, headers={**auth, "Content-Type": "application/json"}, json=payload, timeout=15) + if resp.status_code in (200, 201): + data = resp.json().get("data", {}) + return {"ok": True, "result": {"id": data.get("id"), "text": data.get("text")}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def delete_tweet(self, tweet_id: str) -> Dict[str, Any]: + """Delete a tweet.""" + url = f"{TWITTER_API}/tweets/{tweet_id}" + auth = self._auth_header("DELETE", url) + try: + async with httpx.AsyncClient() as client: + resp = await client.delete(url, headers={**auth}, timeout=15) + if resp.status_code == 200: + return {"ok": True, "result": {"deleted": True}} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def get_user_timeline(self, user_id: Optional[str] = None, max_results: int = 10) -> Dict[str, Any]: + """Get a user's recent tweets.""" + cred = self._load() + uid = user_id or cred.user_id + if not uid: + return {"error": "No user_id available"} + + url = f"{TWITTER_API}/users/{uid}/tweets" + params = {"max_results": str(max_results), "tweet.fields": "created_at,public_metrics,text"} + auth = self._auth_header("GET", url, params) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={**auth}, params=params, timeout=15) + if resp.status_code == 200: + return {"ok": True, "result": resp.json()} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def search_tweets(self, query: str, max_results: int = 10) -> Dict[str, Any]: + """Search recent tweets.""" + url = f"{TWITTER_API}/tweets/search/recent" + params = {"query": query, "max_results": str(max_results), "tweet.fields": "created_at,author_id,public_metrics,text", "expansions": "author_id", "user.fields": "username"} + auth = self._auth_header("GET", url, params) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={**auth}, params=params, timeout=15) + if resp.status_code == 200: + return {"ok": True, "result": resp.json()} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def like_tweet(self, tweet_id: str) -> Dict[str, Any]: + """Like a tweet.""" + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/likes" + auth = self._auth_header("POST", url) + try: + async with httpx.AsyncClient() as client: + resp = await client.post(url, headers={**auth, "Content-Type": "application/json"}, json={"tweet_id": tweet_id}, timeout=15) + if resp.status_code == 200: + return {"ok": True, "result": resp.json().get("data", {})} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def retweet(self, tweet_id: str) -> Dict[str, Any]: + """Retweet a tweet.""" + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/retweets" + auth = self._auth_header("POST", url) + try: + async with httpx.AsyncClient() as client: + resp = await client.post(url, headers={**auth, "Content-Type": "application/json"}, json={"tweet_id": tweet_id}, timeout=15) + if resp.status_code == 200: + return {"ok": True, "result": resp.json().get("data", {})} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def get_user_by_username(self, username: str) -> Dict[str, Any]: + """Look up a user by username.""" + url = f"{TWITTER_API}/users/by/username/{username}" + params = {"user.fields": "id,name,username,description,public_metrics"} + auth = self._auth_header("GET", url, params) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={**auth}, params=params, timeout=15) + if resp.status_code == 200: + return {"ok": True, "result": resp.json().get("data", {})} + return {"error": f"API error: {resp.status_code}", "details": resp.text} + except Exception as e: + return {"error": str(e)} + + async def reply_to_tweet(self, tweet_id: str, text: str) -> Dict[str, Any]: + """Reply to a tweet.""" + return await self.post_tweet(text, reply_to=tweet_id) diff --git a/app/internal_action_interface.py b/app/internal_action_interface.py index 828a6a94..23086ddf 100644 --- a/app/internal_action_interface.py +++ b/app/internal_action_interface.py @@ -150,19 +150,30 @@ def describe_screen(cls) -> Dict[str, str]: return {"description": description, "file_path": img_path} @staticmethod - async def do_chat(message: str, platform: str = "CraftBot TUI") -> None: + async def do_chat( + message: str, + platform: str = "CraftBot TUI", + session_id: Optional[str] = None, + ) -> None: """Record an agent-authored chat message to the event stream. Args: message: The message content to record. platform: The platform the message is sent to (default: "CraftBot TUI"). + session_id: Optional task/session ID for multi-task isolation. """ if InternalActionInterface.state_manager is None: raise RuntimeError("InternalActionInterface not initialized with StateManager.") - InternalActionInterface.state_manager.record_agent_message(message, platform=platform) + InternalActionInterface.state_manager.record_agent_message( + message, session_id=session_id, platform=platform + ) @staticmethod - async def do_chat_with_attachment(message: str, file_path: str) -> Dict[str, Any]: + async def do_chat_with_attachment( + message: str, + file_path: str, + session_id: Optional[str] = None, + ) -> Dict[str, Any]: """ Send a chat message with a single attachment to the user. @@ -171,20 +182,28 @@ async def do_chat_with_attachment(message: str, file_path: str) -> Dict[str, Any Args: message: The message content file_path: Path to the file (absolute or relative to workspace) + session_id: Optional task/session ID for multi-task isolation. Returns: Dict with 'success', 'files_sent', and optionally 'errors' """ - return await InternalActionInterface.do_chat_with_attachments(message, [file_path]) + return await InternalActionInterface.do_chat_with_attachments( + message, [file_path], session_id=session_id + ) @staticmethod - async def do_chat_with_attachments(message: str, file_paths: List[str]) -> Dict[str, Any]: + async def do_chat_with_attachments( + message: str, + file_paths: List[str], + session_id: Optional[str] = None, + ) -> Dict[str, Any]: """ Send a chat message with one or more attachments to the user. Args: message: The message content file_paths: List of paths to the files (absolute or relative to workspace) + session_id: Optional task/session ID for multi-task isolation. Returns: Dict with 'success' (bool), 'files_sent' (int), and optionally 'errors' (list of str) @@ -198,7 +217,9 @@ async def do_chat_with_attachments(message: str, file_paths: List[str]) -> Dict[ # Check if UI adapter supports attachments (browser adapter) if ui_adapter and hasattr(ui_adapter, 'send_message_with_attachments'): - return await ui_adapter.send_message_with_attachments(message, file_paths, sender=agent_name) + return await ui_adapter.send_message_with_attachments( + message, file_paths, sender=agent_name, session_id=session_id + ) else: # Fallback: send message with attachment notes for non-browser adapters if InternalActionInterface.state_manager is None: @@ -206,7 +227,8 @@ async def do_chat_with_attachments(message: str, file_paths: List[str]) -> Dict[ attachment_notes = "\n".join([f"[Attachment: {fp}]" for fp in file_paths]) InternalActionInterface.state_manager.record_agent_message( - f"{message}\n\n{attachment_notes}" + f"{message}\n\n{attachment_notes}", + session_id=session_id, ) # For non-browser adapters, we can't verify files exist, so assume success return {"success": True, "files_sent": len(file_paths), "errors": None} @@ -652,6 +674,17 @@ async def _select_skills_and_action_sets_via_llm( logger.info(f"[TASK] LLM response: skills={selected_skills}, action_sets={selected_sets}") logger.info(f"[TASK] Valid selection: skills={valid_skills}, action_sets={valid_sets}") + # Record skill selection for metrics (skill is "invoked" when selected for prompt) + if valid_skills: + try: + from app.ui_layer.metrics.collector import MetricsCollector + collector = MetricsCollector.get_instance() + if collector: + for skill_name in valid_skills: + collector.record_skill_invocation(skill_name) + except Exception: + pass # Don't fail skill selection if metrics recording fails + return valid_skills, valid_sets except json.JSONDecodeError as e: @@ -881,29 +914,42 @@ def remove_action_sets(cls, sets_to_remove: List[str]) -> Dict[str, Any]: @classmethod def _invalidate_action_selection_caches(cls) -> None: """ - Invalidate action selection session caches when action sets change. + Invalidate and re-create action selection session caches when action sets change. When action sets are added or removed, the cached prompt becomes stale - because the section has changed. This method clears the - session caches for both CLI and GUI action selection. + because the section has changed. This method clears the old + session caches, resets event stream sync points, and re-creates fresh + session caches so the next action selection call sees the updated actions. """ task_id = cls._get_current_task_id() if not task_id or not cls.llm_interface: return try: - # End action selection caches (both CLI and GUI) + # End old action selection caches (both CLI and GUI) cls.llm_interface.end_session_cache(task_id, LLMCallType.ACTION_SELECTION) cls.llm_interface.end_session_cache(task_id, LLMCallType.GUI_ACTION_SELECTION) - # Also reset event stream sync points + # Reset event stream sync points if cls.context_engine: cls.context_engine.reset_event_stream_sync(LLMCallType.ACTION_SELECTION) cls.context_engine.reset_event_stream_sync(LLMCallType.GUI_ACTION_SELECTION) - logger.info(f"[CACHE] Invalidated action selection caches for task {task_id} due to action set change") + # Re-create session caches with fresh system prompt so the next + # action selection call establishes a new session with updated actions + if cls.context_engine: + system_prompt, _ = cls.context_engine.make_prompt( + user_flags={"query": False, "expected_output": False}, + system_flags={"policy": False}, + ) + for call_type in [LLMCallType.ACTION_SELECTION, LLMCallType.GUI_ACTION_SELECTION]: + cache_id = cls.llm_interface.create_session_cache(task_id, call_type, system_prompt) + if cache_id: + logger.debug(f"[CACHE] Re-created session cache {cache_id} for {task_id}:{call_type}") + + logger.info(f"[CACHE] Invalidated and re-created action selection caches for task {task_id} due to action set change") except Exception as e: - logger.warning(f"[CACHE] Failed to invalidate caches for task {task_id}: {e}") + logger.warning(f"[CACHE] Failed to invalidate/re-create caches for task {task_id}: {e}") @classmethod def list_action_sets(cls) -> Dict[str, Any]: diff --git a/app/llm/interface.py b/app/llm/interface.py index 3daf3c18..b21fa5a8 100644 --- a/app/llm/interface.py +++ b/app/llm/interface.py @@ -43,42 +43,10 @@ def __init__( model: Optional[str] = None, api_key: Optional[str] = None, base_url: Optional[str] = None, - db_interface: Optional[Any] = None, temperature: float = 0.0, max_tokens: int = 8000, deferred: bool = False, ) -> None: - # Create log_to_db hook if db_interface provided - log_to_db = None - if db_interface: - def _log_to_db( - system_prompt: Optional[str], - user_prompt: str, - output: str, - status: str, - token_count_input: int, - token_count_output: int, - ) -> None: - input_data: Dict[str, Optional[str]] = { - "system_prompt": system_prompt, - "user_prompt": user_prompt, - } - config: Dict[str, Any] = { - "temperature": self.temperature, - "max_tokens": self.max_tokens, - } - db_interface.log_prompt( - input_data=input_data, - output=output, - provider=self.provider, - model=self.model, - config=config, - status=status, - token_count_input=token_count_input, - token_count_output=token_count_output, - ) - log_to_db = _log_to_db - super().__init__( provider=provider, model=model, @@ -90,8 +58,4 @@ def _log_to_db( get_token_count=_get_token_count, set_token_count=_set_token_count, report_usage=_report_usage, # Report usage to local SQLite storage - log_to_db=log_to_db, ) - - # Store db_interface reference for compatibility - self.db_interface = db_interface diff --git a/app/llm_interface.py b/app/llm_interface.py index 58419dac..efeefc01 100644 --- a/app/llm_interface.py +++ b/app/llm_interface.py @@ -2185,41 +2185,6 @@ def _generate_anthropic( "cached_tokens": cached_tokens, } - # ─────────────────── Internal utilities ─────────────────── - @profile("llm_log_to_db", OperationCategory.DATABASE) - def _log_to_db( - self, - system_prompt: str | None, - user_prompt: str, - output: str, - status: str, - token_count_input: int, - token_count_output: int, - ) -> None: - """Persist prompt/response metadata using the optional `db_interface`.""" - if not self.db_interface: - return - - input_data: Dict[str, Optional[str]] = { - "system_prompt": system_prompt, - "user_prompt": user_prompt, - } - config: Dict[str, Any] = { - "temperature": self.temperature, - "max_tokens": self.max_tokens, - } - - self.db_interface.log_prompt( - input_data=input_data, - output=output, - provider=self.provider, - model=self.model, - config=config, - status=status, - token_count_input=token_count_input, - token_count_output=token_count_output, - ) - # ─────────────────── CLI helper for ad‑hoc testing ─────────────────── def _cli(self) -> None: # pragma: no cover """Run a quick interactive shell for manual testing.""" diff --git a/app/onboarding/interfaces/steps.py b/app/onboarding/interfaces/steps.py index d69ed828..99a8922a 100644 --- a/app/onboarding/interfaces/steps.py +++ b/app/onboarding/interfaces/steps.py @@ -127,11 +127,9 @@ def get_default(self) -> str: class ApiKeyStep: - """API key input step.""" + """API key input step — or Ollama connection setup for the remote provider.""" name = "api_key" - title = "Enter API Key" - description = "Enter your API key for the selected provider." required = True # Maps provider to environment variable name @@ -140,19 +138,39 @@ class ApiKeyStep: "gemini": "GOOGLE_API_KEY", "byteplus": "BYTEPLUS_API_KEY", "anthropic": "ANTHROPIC_API_KEY", - "remote": None, # Ollama doesn't need API key + "remote": None, # Ollama uses a base URL, not an API key } def __init__(self, provider: str = "openai"): self.provider = provider + @property + def title(self) -> str: + if self.provider == "remote": + return "Connect Ollama" + return "Enter API Key" + + @property + def description(self) -> str: + if self.provider == "remote": + return ( + "Connect to your local Ollama instance.\n" + "If Ollama isn't installed yet, we'll help you set it up." + ) + return "Enter your API key for the selected provider." + def get_options(self) -> List[StepOption]: # Free-form input, no options return [] def validate(self, value: Any) -> tuple[bool, Optional[str]]: - # Remote (Ollama) doesn't need API key if self.provider == "remote": + # Value is the Ollama base URL + if not value or not isinstance(value, str): + return True, None # Empty = use default URL + v = value.strip() + if not (v.startswith("http://") or v.startswith("https://")): + return False, "Please enter a valid URL (e.g. http://localhost:11434)" return True, None if not value or not isinstance(value, str): @@ -164,6 +182,8 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: return True, None def get_default(self) -> str: + if self.provider == "remote": + return "http://localhost:11434" # Check settings.json for existing key from app.config import get_api_key return get_api_key(self.provider) diff --git a/app/onboarding/soft/task_creator.py b/app/onboarding/soft/task_creator.py index 9deba099..b7d36468 100644 --- a/app/onboarding/soft/task_creator.py +++ b/app/onboarding/soft/task_creator.py @@ -22,24 +22,34 @@ INTERVIEW FLOW (4 batches): -1. IDENTITY BATCH - Start with warm greeting and ask together: +1. Warm Introduction + Identity Questions +Start with a friendly greeting and ask the first batch using a numbered list: - What should I call you? - What do you do for work? - Where are you based? (Infer timezone from their location, keep this silent) -2. PREFERENCES BATCH - Ask together: + Example opening: + > "Hi there! I'm excited to be your new AI assistant. To personalize your experience, let me ask a few quick questions: + > 1. What should I call you? + > 2. What do you do for work? + > 3. Where are you based?" + +2. Preference Questions (Combined) + - What language do you prefer me to communicate in? - Do you prefer casual or formal communication? - Should I proactively suggest things or wait for instructions? - What types of actions should I ask your approval for? -3. MESSAGING PLATFORM: - - Which messaging platform should I use for notifications? (Telegram/WhatsApp/Discord/Slack/TUI only) +3. Messaging Platform + - Which messaging platform should I use for notifications? (Telegram/WhatsApp/Discord/Slack/CraftBot Interface only) -4. LIFE GOALS (most important question): +4. Life Goals & Assistance - What are your life goals or aspirations? - What would you like me to help you with generally? +Refer to the "user-profile-interview" skill for questions and style. + IMPORTANT GUIDELINES: - Ask related questions together using a numbered list format - Be warm and conversational, not robotic @@ -49,10 +59,17 @@ - If user is annoyed by this interview or refuse to answer, just skip, and end task. After gathering ALL information: -1. Read agent_file_system/USER.md -2. Update USER.md with the collected information using stream_edit (including Life Goals section) -3. Suggest 3-5 specific tasks that can help them achieve their life goals using CraftBot's automation capabilities -4. End the task immediately with task_end (do NOT wait for confirmation) +1. Tell the user to wait a moment while you update their preference +2. Read agent_file_system/USER.md +3. Update USER.md with the collected information using stream_edit (including Language in Communication Preferences and Life Goals section) +4. Suggest tasks based on life goals: Send a message suggesting 1-3 tasks that CraftBot can help with to improve their life and get closer to achieving their goals. Focus on: + - Tasks that leverage CraftBot's automation capabilities + - Recurring tasks that save time in the long run + - Immediate tasks that can show impact in short-term + - Bite-size tasks that is specialized, be specific with numbers or actionable items. DO NOT suggest generic task. + - Avoid giving mutliple approaches in each suggested task, provide the BEST option to achieve goal. + - Tasks that align with their work and personal aspirations +5. End the task immediately with task_end (do NOT wait for confirmation) Start with: "Hi! I'm excited to be your AI assistant. To personalize your experience, let me ask a few quick questions:" then list the first batch. """ @@ -74,7 +91,7 @@ def create_soft_onboarding_task(task_manager: "TaskManager") -> str: task_id = task_manager.create_task( task_name="User Profile Interview", task_instruction=SOFT_ONBOARDING_TASK_INSTRUCTION, - mode="complex", + mode="simple", action_sets=["file_operations", "core"], selected_skills=["user-profile-interview"] ) diff --git a/app/proactive/manager.py b/app/proactive/manager.py index 6bd2e680..4c9baef7 100644 --- a/app/proactive/manager.py +++ b/app/proactive/manager.py @@ -309,13 +309,10 @@ def update_planner_output(self, scope: str, date_info: str, content: str) -> Non logger.info(f"[PROACTIVE] Updated planner output: {key}") def get_due_tasks(self, frequency: str) -> List[RecurringTask]: - """Get tasks that are due for execution. - - This is used by the heartbeat processor to determine which tasks - should be executed for the current heartbeat. + """Get tasks that are due for execution for a specific frequency. Args: - frequency: The current heartbeat frequency + frequency: The heartbeat frequency to check Returns: List of tasks that should run @@ -328,6 +325,31 @@ def get_due_tasks(self, frequency: str) -> List[RecurringTask]: logger.info(f"[PROACTIVE] Found {len(due_tasks)} due tasks for {frequency} heartbeat") return due_tasks + def get_all_due_tasks(self) -> List[RecurringTask]: + """Get all tasks that are due across every frequency. + + Used by the unified heartbeat to collect hourly, daily, weekly, + and monthly tasks that should execute right now based on their + time/day fields and last_run timestamp. + + Returns: + List of due tasks sorted by priority (lower = higher priority) + """ + all_enabled = self.get_tasks(enabled_only=True) + due = [t for t in all_enabled if t.should_run()] + due.sort(key=lambda t: t.priority) + + if due: + freq_counts = {} + for t in due: + freq_counts[t.frequency] = freq_counts.get(t.frequency, 0) + 1 + summary = ", ".join(f"{cnt} {f}" for f, cnt in freq_counts.items()) + logger.info(f"[PROACTIVE] Found {len(due)} due tasks across all frequencies: {summary}") + else: + logger.info("[PROACTIVE] No due tasks found across any frequency") + + return due + # Singleton instance (initialized by InternalActionInterface) _manager: Optional[ProactiveManager] = None diff --git a/app/proactive/types.py b/app/proactive/types.py index 058ee586..fcf5f62e 100644 --- a/app/proactive/types.py +++ b/app/proactive/types.py @@ -6,7 +6,8 @@ """ from dataclasses import dataclass, field -from datetime import datetime +import calendar +from datetime import datetime, timedelta from typing import Any, Dict, List, Optional @@ -109,21 +110,215 @@ class RecurringTask: MAX_OUTCOME_HISTORY = 5 - def should_run(self, current_frequency: str) -> bool: - """Check if this task should run for the given frequency. + # Grace period: tasks must be picked up within this window after their + # scheduled time, otherwise the run is skipped until the next period. + # Set to 30 minutes to match the heartbeat interval (fires at :00 and :30). + GRACE_PERIOD = timedelta(minutes=30) + + def should_run(self, current_frequency: str = "") -> bool: + """Check if this task should run. + + When ``current_frequency`` is given, only tasks matching that exact + frequency are considered (legacy per-frequency heartbeat behaviour). + When empty or ``"all"``, the method checks the task's own frequency + against the current date/time to decide if it is due. + + Tasks with a scheduled time have a 30-minute grace period. If the + heartbeat fires more than 30 minutes after the target time, the run + is skipped until the next period (no catch-up runs). Args: - current_frequency: The frequency being processed (hourly, daily, etc.) + current_frequency: The frequency being processed, or "" / "all" + to check all frequencies against current time. Returns: True if the task should run, False otherwise. """ if not self.enabled: return False - if self.frequency != current_frequency: - return False - # Conditions are checked by the heartbeat processor - return True + + # Legacy per-frequency filter + if current_frequency and current_frequency != "all": + return self.frequency == current_frequency + + # Unified heartbeat: check if this task is due right now + now = datetime.now() + + if self.frequency == "hourly": + # Hourly tasks are always due on every heartbeat + return True + + if self.frequency == "daily": + # Check if already ran today + if self.last_run and self.last_run.date() == now.date(): + return False + # Daily tasks: check time field if present + if self.time: + task_hour, task_minute = (int(p) for p in self.time.split(":")) + target_time = now.replace(hour=task_hour, minute=task_minute, second=0, microsecond=0) + if now < target_time: + return False # Too early + if now > target_time + self.GRACE_PERIOD: + return False # Missed the window, skip until tomorrow + return True + + if self.frequency == "weekly": + # Check if already ran this week + if self.last_run and self.last_run.isocalendar()[1] == now.isocalendar()[1] and self.last_run.year == now.year: + return False + # Weekly tasks: check day field + if self.day: + today_name = now.strftime("%A").lower() + if today_name != self.day.lower(): + return False + # Check time if present + if self.time: + task_hour, task_minute = (int(p) for p in self.time.split(":")) + target_time = now.replace(hour=task_hour, minute=task_minute, second=0, microsecond=0) + if now < target_time: + return False + if now > target_time + self.GRACE_PERIOD: + return False # Missed the window, skip until next week + return True + + if self.frequency == "monthly": + # Check if already ran this month + if self.last_run and self.last_run.month == now.month and self.last_run.year == now.year: + return False + # Monthly tasks: check day field (day of month) + if self.day: + try: + target_day = int(self.day) + if now.day != target_day: + return False + except ValueError: + pass # Non-numeric day, skip check + # Check time if present + if self.time: + task_hour, task_minute = (int(p) for p in self.time.split(":")) + target_time = now.replace(hour=task_hour, minute=task_minute, second=0, microsecond=0) + if now < target_time: + return False + if now > target_time + self.GRACE_PERIOD: + return False # Missed the window, skip until next month + return True + + return False + + @staticmethod + def _next_heartbeat(dt: datetime) -> datetime: + """Snap a datetime to the next clock-aligned heartbeat slot (:00 or :30). + + Heartbeats fire at fixed 30-minute intervals aligned to the clock. + """ + if dt.minute < 30: + return dt.replace(minute=30, second=0, microsecond=0) + return (dt + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) + + def calculate_next_run(self) -> Optional[datetime]: + """Calculate the next execution time for this task. + + Returns the next datetime when this task will actually execute, + snapped to the next heartbeat slot (every 30 min at :00 and :30). + Returns None for disabled tasks. + """ + if not self.enabled: + return None + + now = datetime.now() + task_hour, task_minute = 0, 0 + if self.time: + parts = self.time.split(":") + task_hour, task_minute = int(parts[0]), int(parts[1]) + + if self.frequency == "hourly": + # Hourly tasks run on every heartbeat (every 30 min) + return self._next_heartbeat(now) + + if self.frequency == "daily": + today_at_time = now.replace(hour=task_hour, minute=task_minute, second=0, microsecond=0) + if self.last_run and self.last_run.date() == now.date(): + # Already ran today — next is tomorrow + return self._next_heartbeat(today_at_time + timedelta(days=1) - timedelta(seconds=1)) + if now < today_at_time: + # Time hasn't passed yet — snap target time to heartbeat + return self._next_heartbeat(today_at_time - timedelta(seconds=1)) + if self.time and now <= today_at_time + self.GRACE_PERIOD: + # Within grace period — next heartbeat will pick it up + return self._next_heartbeat(now) + # Missed the window — skip to tomorrow + return self._next_heartbeat(today_at_time + timedelta(days=1) - timedelta(seconds=1)) + + if self.frequency == "weekly": + day_names = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + target_day_name = (self.day or "monday").lower() + target_weekday = day_names.index(target_day_name) if target_day_name in day_names else 0 + + days_ahead = target_weekday - now.weekday() + if days_ahead < 0: + days_ahead += 7 + + next_date = now + timedelta(days=days_ahead) + next_time = next_date.replace(hour=task_hour, minute=task_minute, second=0, microsecond=0) + + if self.last_run and self.last_run.isocalendar()[1] == now.isocalendar()[1] and self.last_run.year == now.year: + # Already ran this week — next week + next_time = (now + timedelta(days=(7 - now.weekday() + target_weekday))).replace( + hour=task_hour, minute=task_minute, second=0, microsecond=0 + ) + if next_time <= now: + next_time += timedelta(weeks=1) + return self._next_heartbeat(next_time - timedelta(seconds=1)) + + if next_time <= now: + # Target time has passed this week + if self.time and now <= next_time + self.GRACE_PERIOD: + # Within grace period + return self._next_heartbeat(now) + # Missed the window — skip to next week + next_time += timedelta(weeks=1) + + return self._next_heartbeat(next_time - timedelta(seconds=1)) + + if self.frequency == "monthly": + try: + target_day = int(self.day) if self.day else 1 + except ValueError: + target_day = 1 + + max_day = calendar.monthrange(now.year, now.month)[1] + clamped_day = min(target_day, max_day) + this_month_time = now.replace(day=clamped_day, hour=task_hour, minute=task_minute, second=0, microsecond=0) + + if self.last_run and self.last_run.month == now.month and self.last_run.year == now.year: + # Already ran this month — go to next month + if now.month == 12: + ny, nm = now.year + 1, 1 + else: + ny, nm = now.year, now.month + 1 + clamped = min(target_day, calendar.monthrange(ny, nm)[1]) + target = now.replace(year=ny, month=nm, day=clamped, + hour=task_hour, minute=task_minute, second=0, microsecond=0) + return self._next_heartbeat(target - timedelta(seconds=1)) + + if now < this_month_time: + return self._next_heartbeat(this_month_time - timedelta(seconds=1)) + + if self.time and now <= this_month_time + self.GRACE_PERIOD: + # Within grace period + return self._next_heartbeat(now) + + # Missed the window — skip to next month + if now.month == 12: + ny, nm = now.year + 1, 1 + else: + ny, nm = now.year, now.month + 1 + clamped = min(target_day, calendar.monthrange(ny, nm)[1]) + target = now.replace(year=ny, month=nm, day=clamped, + hour=task_hour, minute=task_minute, second=0, microsecond=0) + return self._next_heartbeat(target - timedelta(seconds=1)) + + return None def add_outcome( self, @@ -150,6 +345,7 @@ def add_outcome( # Update run metadata self.last_run = outcome.timestamp self.run_count += 1 + self.next_run = self.calculate_next_run() def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for YAML serialization.""" diff --git a/app/scheduler/manager.py b/app/scheduler/manager.py index c9870123..05b52698 100644 --- a/app/scheduler/manager.py +++ b/app/scheduler/manager.py @@ -164,13 +164,14 @@ def add_schedule( # Add to schedules self._schedules[schedule_id] = task - # Save config - self._save_config() - - # Start loop if running and enabled + # Start loop if running and enabled (BEFORE saving config) + # This ensures the loop is in _scheduler_tasks when reload() runs if self._is_running and enabled: asyncio.create_task(self._start_schedule_loop(schedule_id)) + # Save config (triggers hot-reload via file watcher) + self._save_config() + logger.info(f"[SCHEDULER] Added schedule: {schedule_id} - {name}") return schedule_id @@ -276,6 +277,78 @@ def get_schedule(self, schedule_id: str) -> Optional[ScheduledTask]: """Get a schedule by ID.""" return self._schedules.get(schedule_id) + async def queue_immediate_trigger( + self, + name: str, + instruction: str, + priority: int = 50, + mode: str = "simple", + action_sets: Optional[List[str]] = None, + skills: Optional[List[str]] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Queue a trigger for immediate execution. + + Creates a new session and queues it to the TriggerQueue + for immediate processing by the scheduler. + + Args: + name: Human-readable name for the task + instruction: What the agent should do + priority: Trigger priority (lower = higher priority) + mode: Task mode ("simple" or "complex") + action_sets: Action sets to enable for the task + skills: Skills to load for the task + payload: Additional payload data to pass to the task + + Returns: + Dictionary with status, session_id, and message + """ + if not self._trigger_queue: + return { + "status": "error", + "error": "Trigger queue not initialized" + } + + # Generate unique session ID + session_id = f"immediate_{uuid.uuid4().hex[:8]}_{int(time.time())}" + + # Build trigger payload (matching the format used by _fire_schedule) + trigger_payload = { + "type": "scheduled", + "schedule_id": f"immediate_{uuid.uuid4().hex[:8]}", + "schedule_name": name, + "instruction": instruction, + "mode": mode, + "action_sets": action_sets or [], + "skills": skills or [], + **(payload or {}), + } + + # Create trigger + trigger = Trigger( + fire_at=time.time(), # Fire immediately + priority=priority, + next_action_description=f"[Immediate] {name}: {instruction}", + payload=trigger_payload, + session_id=session_id, + ) + + # Queue the trigger + await self._trigger_queue.put(trigger) + + logger.info(f"[SCHEDULER] Queued immediate trigger: {name} (session: {session_id})") + + return { + "status": "ok", + "schedule_id": session_id, + "name": name, + "recurring": False, + "scheduled_for": "immediate", + "message": f"Task '{name}' queued for immediate execution (session: {session_id})" + } + def get_status(self) -> Dict[str, Any]: """Get scheduler status for monitoring.""" return { @@ -296,18 +369,76 @@ def get_status(self) -> Dict[str, Any]: ], } + async def reload(self, config_path: Optional[Path] = None) -> Dict[str, Any]: + """ + Hot-reload scheduler configuration from disk. + + Stops all loops, clears schedules, re-reads config, and restarts. + """ + try: + # 1. Stop all existing loops + for schedule_id in list(self._scheduler_tasks.keys()): + await self._stop_schedule_loop(schedule_id) + + # 2. Clear schedules + self._schedules.clear() + + # 3. Load config from disk + config = self._load_config() + self._master_enabled = config.enabled + + if not config.enabled: + logger.info("[SCHEDULER] Scheduler disabled in config") + return {"success": True, "message": "Scheduler disabled", "total": 0} + + # 4. Add schedules and start loops + for task in config.schedules: + self._schedules[task.id] = task + if self._is_running and task.enabled: + await self._start_schedule_loop(task.id) + + logger.info(f"[SCHEDULER] Reloaded {len(self._schedules)} schedule(s)") + return { + "success": True, + "message": f"Reloaded {len(self._schedules)} schedules", + "total": len(self._schedules) + } + except Exception as e: + logger.error(f"[SCHEDULER] Reload failed: {e}") + return {"success": False, "message": str(e), "total": 0} + + async def _stop_schedule_loop(self, schedule_id: str) -> None: + """Stop a background loop for a schedule.""" + if schedule_id not in self._scheduler_tasks: + return + + task = self._scheduler_tasks[schedule_id] + if not task.done(): + try: + task.cancel() + await task + except (asyncio.CancelledError, RuntimeError, Exception): + pass # Ignore all errors during cancellation + + del self._scheduler_tasks[schedule_id] + # ─────────────── Internal Methods ─────────────── async def _start_schedule_loop(self, schedule_id: str) -> None: """Start a background loop for a schedule.""" if schedule_id in self._scheduler_tasks: - return # Already running + existing_task = self._scheduler_tasks[schedule_id] + if not existing_task.done(): + return # Already running + # Task exists but is done - clean up before creating new one + del self._scheduler_tasks[schedule_id] + logger.debug(f"[SCHEDULER] Cleaned up done task for: {schedule_id}") task = asyncio.create_task(self._schedule_loop(schedule_id)) self._scheduler_tasks[schedule_id] = task schedule = self._schedules[schedule_id] - logger.debug(f"[SCHEDULER] Started loop for: {schedule_id} - {schedule.name}") + logger.info(f"[SCHEDULER] Started loop for: {schedule_id} - {schedule.name}") async def _schedule_loop(self, schedule_id: str) -> None: """ @@ -315,10 +446,16 @@ async def _schedule_loop(self, schedule_id: str) -> None: Calculates delay to next fire time, sleeps, then fires the trigger. """ + logger.info(f"[SCHEDULER] Loop starting for: {schedule_id}") + while self._is_running: try: schedule = self._schedules.get(schedule_id) - if not schedule or not schedule.enabled: + if not schedule: + logger.warning(f"[SCHEDULER] Schedule {schedule_id} not found, exiting loop") + break + if not schedule.enabled: + logger.info(f"[SCHEDULER] Schedule {schedule_id} disabled, exiting loop") break # Calculate next fire time @@ -332,18 +469,27 @@ async def _schedule_loop(self, schedule_id: str) -> None: delay = next_fire - now if delay > 0: next_fire_str = datetime.fromtimestamp(next_fire).strftime("%Y-%m-%d %H:%M:%S") - logger.debug( - f"[SCHEDULER] {schedule_id} sleeping until {next_fire_str} " - f"({delay / 3600:.2f} hours)" + logger.info( + f"[SCHEDULER] {schedule_id} ({schedule.name}) sleeping until {next_fire_str} " + f"({delay:.1f}s / {delay / 60:.1f}min)" ) await asyncio.sleep(delay) # Check if still running and schedule still exists schedule = self._schedules.get(schedule_id) - if not schedule or not schedule.enabled or not self._is_running: + logger.info(f"[SCHEDULER] {schedule_id} woke up, checking conditions before fire") + if not schedule: + logger.warning(f"[SCHEDULER] {schedule_id} schedule was removed while sleeping") + break + if not schedule.enabled: + logger.info(f"[SCHEDULER] {schedule_id} was disabled while sleeping") + break + if not self._is_running: + logger.info(f"[SCHEDULER] {schedule_id} scheduler stopped while sleeping") break # Fire the schedule + logger.info(f"[SCHEDULER] {schedule_id} about to fire!") await self._fire_schedule(schedule) # Small delay before recalculating (for interval schedules) @@ -354,13 +500,17 @@ async def _schedule_loop(self, schedule_id: str) -> None: await asyncio.sleep(60) except asyncio.CancelledError: - logger.debug(f"[SCHEDULER] Loop cancelled for: {schedule_id}") + logger.info(f"[SCHEDULER] Loop cancelled for: {schedule_id}") break except Exception as e: - logger.warning(f"[SCHEDULER] Error in loop for {schedule_id}: {e}") + logger.error(f"[SCHEDULER] Error in loop for {schedule_id}: {e}") + import traceback + logger.error(f"[SCHEDULER] Traceback: {traceback.format_exc()}") # Wait before retrying to avoid tight error loops await asyncio.sleep(60) + logger.info(f"[SCHEDULER] Loop exited for: {schedule_id}") + async def _fire_schedule(self, schedule: ScheduledTask) -> None: """ Fire a scheduled task trigger. @@ -426,8 +576,8 @@ def _load_config(self) -> SchedulerConfig: try: with open(self._config_path, "r", encoding="utf-8") as f: data = json.load(f) - except json.JSONDecodeError as e: - logger.error(f"[SCHEDULER] Invalid JSON in config: {e}") + except (json.JSONDecodeError, Exception) as e: + logger.warning(f"[SCHEDULER] Config read error, using defaults: {e}") return SchedulerConfig() # Parse schedules @@ -466,13 +616,22 @@ def _save_config(self) -> None: # Ensure directory exists self._config_path.parent.mkdir(parents=True, exist_ok=True) - # Write atomically (write to temp, then rename) + # Write atomically (write to temp, then rename). + # On Windows the rename can fail with "Access is denied" when + # another process (e.g. an IDE) holds the target file open, so + # fall back to a direct overwrite in that case. temp_path = self._config_path.with_suffix(".tmp") + data = json.dumps(config.to_dict(), indent=2) try: with open(temp_path, "w", encoding="utf-8") as f: - json.dump(config.to_dict(), f, indent=2) - temp_path.replace(self._config_path) + f.write(data) + try: + temp_path.replace(self._config_path) + except OSError: + # Atomic rename failed (Windows lock) — write directly + with open(self._config_path, "w", encoding="utf-8") as f: + f.write(data) + if temp_path.exists(): + temp_path.unlink() except Exception as e: logger.error(f"[SCHEDULER] Failed to save config: {e}") - if temp_path.exists(): - temp_path.unlink() diff --git a/app/state/state_manager.py b/app/state/state_manager.py index f03a9502..e122a1c0 100644 --- a/app/state/state_manager.py +++ b/app/state/state_manager.py @@ -179,6 +179,8 @@ def reset(self) -> None: STATE.agent_properties: AgentProperties = AgentProperties( current_task_id="", action_count=0 ) + # Reset main state to clear active_task_ids and task_summaries + self._main_state = MainState() if self.event_stream_manager: self.event_stream_manager.clear_all() self.clean_state() diff --git a/app/tui/interface.py b/app/tui/interface.py index 4919257b..f25b85a1 100644 --- a/app/tui/interface.py +++ b/app/tui/interface.py @@ -45,6 +45,7 @@ def __init__( enable_action_panel=True, # TUI has action panel ) self._controller = UIController(agent, self._config) + agent.ui_controller = self._controller # Back-reference for event emission # Create TUI adapter self._adapter = TUIAdapter(self._controller) diff --git a/app/tui/settings.py b/app/tui/settings.py index db881831..54d95638 100644 --- a/app/tui/settings.py +++ b/app/tui/settings.py @@ -100,6 +100,41 @@ def save_settings_to_json(provider: str, api_key: str) -> bool: save_settings_to_env = save_settings_to_json +def save_remote_endpoint(url: str) -> bool: + """Save the Ollama (remote) base URL to settings.json. + + Args: + url: The base URL for the Ollama server (e.g. http://localhost:11434) + + Returns: + True if saved successfully, False otherwise + """ + try: + settings = _load_settings() + + if "model" not in settings: + settings["model"] = {} + settings["model"]["llm_provider"] = "remote" + settings["model"]["vlm_provider"] = "remote" + + if "endpoints" not in settings: + settings["endpoints"] = {} + settings["endpoints"]["remote"] = url + + if not _save_settings(settings): + return False + + from app.config import reload_settings + reload_settings() + + logger.info(f"[SETTINGS] Saved remote endpoint={url} to settings.json") + return True + + except Exception as e: + logger.error(f"[SETTINGS] Failed to save remote endpoint: {e}") + return False + + def get_api_key_env_name(provider: str) -> Optional[str]: """Get the environment variable name for a provider's API key.""" if provider not in PROVIDER_CONFIG: diff --git a/app/ui_layer/adapters/base.py b/app/ui_layer/adapters/base.py index 9d9965af..f8f9aa8f 100644 --- a/app/ui_layer/adapters/base.py +++ b/app/ui_layer/adapters/base.py @@ -233,6 +233,12 @@ def _subscribe_events(self) -> None: self._unsubscribers.append( bus.subscribe(UIEventType.GUI_MODE_CHANGED, self._handle_gui_mode_change) ) + self._unsubscribers.append( + bus.subscribe(UIEventType.WAITING_FOR_USER, self._handle_waiting_for_user) + ) + self._unsubscribers.append( + bus.subscribe(UIEventType.TASK_UPDATE, self._handle_task_update) + ) # Footage events self._unsubscribers.append( @@ -267,7 +273,10 @@ def _handle_agent_message(self, event: UIEvent) -> None: agent_name = onboarding_manager.state.agent_name or "Agent" asyncio.create_task( self._display_chat_message( - agent_name, event.data.get("message", ""), "agent" + agent_name, + event.data.get("message", ""), + "agent", + task_session_id=event.task_id, ) ) @@ -386,6 +395,23 @@ def _handle_gui_mode_change(self, event: UIEvent) -> None: if self.footage_component: self.footage_component.set_visible(event.data.get("gui_mode", False)) + def _handle_waiting_for_user(self, event: UIEvent) -> None: + """Handle waiting for user event - update task status to waiting.""" + task_id = event.data.get("task_id", "") + if task_id and self.action_panel: + asyncio.create_task( + self.action_panel.update_item(task_id, "waiting") + ) + + def _handle_task_update(self, event: UIEvent) -> None: + """Handle task update event - update task status.""" + task_id = event.data.get("task_id", "") + status = event.data.get("status", "running") + if task_id and self.action_panel: + asyncio.create_task( + self.action_panel.update_item(task_id, status) + ) + def _handle_footage_update(self, event: UIEvent) -> None: """Handle footage update event.""" if self.footage_component: @@ -415,6 +441,7 @@ async def _display_chat_message( label: str, message: str, style: str, + task_session_id: Optional[str] = None, ) -> None: """ Display a chat message. @@ -423,6 +450,7 @@ async def _display_chat_message( label: Message sender label message: Message content style: Style identifier + task_session_id: Optional task session ID for reply feature """ import time @@ -432,6 +460,7 @@ async def _display_chat_message( content=message, style=style, timestamp=time.time(), + task_session_id=task_session_id, ) ) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index bbf5b915..1b7dff24 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -53,6 +53,7 @@ update_model_settings, test_connection, validate_can_save, + get_ollama_models, # MCP settings list_mcp_servers, add_mcp_server_from_json, @@ -184,8 +185,8 @@ def _init_storage(self) -> None: from app.usage.chat_storage import get_chat_storage, StoredChatMessage self._storage = get_chat_storage() - # Load recent messages from storage - stored_messages = self._storage.get_recent_messages(limit=200) + # Load recent messages from storage (initial page) + stored_messages = self._storage.get_recent_messages(limit=50) for stored in stored_messages: attachments = None if stored.attachments: @@ -206,6 +207,7 @@ def _init_storage(self) -> None: timestamp=stored.timestamp, message_id=stored.message_id, attachments=attachments, + task_session_id=stored.task_session_id, )) except Exception: # Storage may not be available, continue without persistence @@ -238,6 +240,7 @@ async def append_message(self, message: ChatMessage) -> None: style=message.style, timestamp=message.timestamp, attachments=attachments_data, + task_session_id=message.task_session_id, ) self._storage.insert_message(stored) except Exception: @@ -265,6 +268,10 @@ async def append_message(self, message: ChatMessage) -> None: for att in message.attachments ] + # Include task session ID for reply feature + if message.task_session_id: + message_data["taskSessionId"] = message.task_session_id + await self._adapter._broadcast({ "type": "chat_message", "data": message_data, @@ -290,9 +297,50 @@ def scroll_to_bottom(self) -> None: pass def get_messages(self) -> List[ChatMessage]: - """Get all messages.""" + """Get all loaded messages.""" return self._messages.copy() + def get_messages_before(self, before_timestamp: float, limit: int = 50) -> List[ChatMessage]: + """Get older messages from storage before a given timestamp.""" + if not self._storage: + return [] + try: + stored = self._storage.get_messages_before(before_timestamp, limit=limit) + messages = [] + for s in stored: + attachments = None + if s.attachments: + attachments = [ + Attachment( + name=att.get("name", ""), + path=att.get("path", ""), + type=att.get("type", ""), + size=att.get("size", 0), + url=att.get("url", ""), + ) + for att in s.attachments + ] + messages.append(ChatMessage( + sender=s.sender, + content=s.content, + style=s.style, + timestamp=s.timestamp, + message_id=s.message_id, + attachments=attachments, + )) + return messages + except Exception: + return [] + + def get_total_count(self) -> int: + """Get total message count from storage.""" + if not self._storage: + return len(self._messages) + try: + return self._storage.get_message_count() + except Exception: + return len(self._messages) + class BrowserActionPanelComponent(ActionPanelProtocol): """Browser action panel component.""" @@ -312,8 +360,8 @@ def _init_storage(self) -> None: # Mark any stale running items as cancelled from previous session self._storage.mark_running_as_cancelled() - # Load recent actions from storage - stored_items = self._storage.get_recent_items(limit=200) + # Load recent tasks (and their child actions) from storage + stored_items = self._storage.get_recent_tasks_with_actions(task_limit=15) for stored in stored_items: self._items.append(ActionItem( id=stored.id, @@ -545,9 +593,42 @@ def select_task(self, task_id: Optional[str]) -> None: pass def get_items(self) -> List[ActionItem]: - """Get all items.""" + """Get all loaded items.""" return self._items.copy() + def get_tasks_before(self, before_timestamp: float, task_limit: int = 15) -> List[ActionItem]: + """Get older tasks (and their child actions) from storage.""" + if not self._storage: + return [] + try: + stored = self._storage.get_tasks_before(before_timestamp, task_limit=task_limit) + return [ + ActionItem( + id=s.id, + name=s.name, + status=s.status, + item_type=s.item_type, + parent_id=s.parent_id, + created_at=s.created_at, + completed_at=s.completed_at, + input_data=s.input_data, + output_data=s.output_data, + error_message=s.error_message, + ) + for s in stored + ] + except Exception: + return [] + + def get_task_count(self) -> int: + """Get total task count (not actions) from storage.""" + if not self._storage: + return len([i for i in self._items if i.item_type == 'task']) + try: + return self._storage.get_task_count() + except Exception: + return len([i for i in self._items if i.item_type == 'task']) + class BrowserStatusBarComponent(StatusBarProtocol): """Browser status bar component.""" @@ -648,6 +729,9 @@ def __init__( self._metrics_collector = MetricsCollector(controller.agent) self._metrics_task: Optional[asyncio.Task] = None + # Track active OAuth tasks for cancellation support + self._oauth_tasks: Dict[str, asyncio.Task] = {} + @property def theme_adapter(self) -> ThemeAdapter: return self._theme_adapter @@ -673,6 +757,36 @@ def metrics_collector(self) -> MetricsCollector: """Get the metrics collector for dashboard data.""" return self._metrics_collector + async def submit_message( + self, + message: str, + reply_context: Optional[Dict[str, Any]] = None + ) -> None: + """ + Submit a message from the user with optional reply context. + + Overrides base class to handle reply-to-chat/task feature. + Appends reply context to the message before routing to the agent. + + Args: + message: The user's input message + reply_context: Optional dict with {sessionId?: str, originalMessage: str} + """ + agent_context = message + + # Add reply context note (similar to attachment_note pattern) + if reply_context and reply_context.get("originalMessage"): + reply_note = f"\n\n[REPLYING TO PREVIOUS AGENT MESSAGE]:\n{reply_context['originalMessage']}" + agent_context = message + reply_note + + # Pass to controller with target session ID if replying + target_session_id = reply_context.get("sessionId") if reply_context else None + await self._controller.submit_message( + agent_context, + self._adapter_id, + target_session_id=target_session_id + ) + def _handle_task_start(self, event: UIEvent) -> None: """Handle task start event with metrics tracking.""" # Call parent implementation @@ -721,6 +835,25 @@ def _handle_reasoning(self, event: UIEvent) -> None: async def _on_start(self) -> None: """Start the browser interface.""" from aiohttp import web + from app.onboarding import onboarding_manager + import uuid + + # Display welcome system message if soft onboarding is pending + if onboarding_manager.needs_soft_onboarding: + welcome_message = ChatMessage( + sender="System", + content="""**Welcome to CraftBot** + +CraftBot can perform virtually any computer-based task by configuring the right MCP servers, skills, or connecting to apps. + +If you need help setting up MCP servers or skills, just ask the agent. + +A quick Q&A will now begin to understand your preferences and serve you better:""", + style="system", + timestamp=time.time(), + message_id=f"welcome-{uuid.uuid4().hex[:8]}", + ) + self._chat._messages.insert(0, welcome_message) self._app = web.Application() @@ -738,17 +871,21 @@ async def _on_start(self) -> None: if assets_path.exists(): self._app.router.add_static("/assets/", assets_path) - # Serve favicon - favicon_path = frontend_dist / "favicon.svg" - if favicon_path.exists(): - self._app.router.add_get( - "/favicon.svg", - lambda _: web.FileResponse(favicon_path) - ) + # Serve static files from dist/ (public/ files copied by Vite build) + # This must come before the SPA catch-all so images, fonts, etc. are served directly + _dist = frontend_dist # capture for closure + + async def _static_or_spa(request: web.Request) -> web.StreamResponse: + """Serve static file from dist/ if it exists, otherwise index.html for SPA routing.""" + req_path = request.match_info.get("path", "") + if req_path: + file_path = _dist / req_path + if file_path.is_file(): + return web.FileResponse(file_path) + return web.FileResponse(_dist / "index.html") - # Serve index.html for all non-API routes (SPA routing) self._app.router.add_get("/", self._spa_handler) - self._app.router.add_get("/{path:.*}", self._spa_handler) + self._app.router.add_get("/{path:.*}", _static_or_spa) else: # Fallback to inline HTML for development without build self._app.router.add_get("/", self._index_handler) @@ -851,16 +988,23 @@ async def _websocket_handler(self, request: "web.Request") -> "web.WebSocketResp break except json.JSONDecodeError as e: # Continue on JSON errors, don't close connection - pass + import traceback + error_detail = f"JSON decode error: {e}" + print(f"[BROWSER ADAPTER] {error_detail}") + await self._broadcast_error_to_chat(error_detail) except Exception as e: # Continue on message errors, don't close connection - pass + import traceback + error_detail = f"WebSocket message error: {type(e).__name__}: {e}\n{traceback.format_exc()}" + print(f"[BROWSER ADAPTER] {error_detail}") + await self._broadcast_error_to_chat(error_detail) except asyncio.CancelledError: - pass - except (ClientConnectionResetError, ConnectionResetError): - pass # Silently handle expected connection errors + print("[BROWSER ADAPTER] WebSocket cancelled") + except (ClientConnectionResetError, ConnectionResetError) as e: + print(f"[BROWSER ADAPTER] WebSocket connection reset: {type(e).__name__}: {e}") except Exception as e: - pass + import traceback + print(f"[BROWSER ADAPTER] WebSocket loop error: {type(e).__name__}: {e}\n{traceback.format_exc()}") finally: self._ws_clients.discard(ws) @@ -871,16 +1015,17 @@ async def _handle_ws_message(self, data: Dict[str, Any]) -> None: msg_type = data.get("type") if msg_type == "message": - # User sent a message (may include attachments) + # User sent a message (may include attachments and/or reply context) content = data.get("content", "") attachments = data.get("attachments", []) + reply_context = data.get("replyContext") # {sessionId?: str, originalMessage: str} if attachments: # Message with attachments - use custom handler - await self._handle_chat_message_with_attachments(content, attachments) + await self._handle_chat_message_with_attachments(content, attachments, reply_context) elif content: # Regular message without attachments - use normal flow - await self.submit_message(content) + await self.submit_message(content, reply_context) elif msg_type == "chat_attachment_upload": # Upload attachment for chat message @@ -892,10 +1037,23 @@ async def _handle_ws_message(self, data: Dict[str, Any]) -> None: if command: await self.submit_message(command) + elif msg_type == "chat_history": + before_timestamp = data.get("beforeTimestamp") + limit = data.get("limit", 50) + await self._handle_chat_history(before_timestamp, limit) + + elif msg_type == "action_history": + before_timestamp = data.get("beforeTimestamp") + limit = data.get("limit", 15) + await self._handle_action_history(before_timestamp, limit) + # File operations elif msg_type == "file_list": directory = data.get("directory", "") - await self._handle_file_list(directory) + offset = data.get("offset", 0) + limit = data.get("limit", 50) + search = data.get("search", "") + await self._handle_file_list(directory, offset=offset, limit=limit, search=search) elif msg_type == "file_read": file_path = data.get("path", "") @@ -1072,6 +1230,10 @@ async def _handle_ws_message(self, data: Dict[str, Any]) -> None: elif msg_type == "model_validate_save": await self._handle_model_validate_save(data) + elif msg_type == "ollama_models_get": + base_url = data.get("baseUrl") + await self._handle_ollama_models_get(base_url) + # MCP settings operations elif msg_type == "mcp_list": await self._handle_mcp_list() @@ -1165,11 +1327,33 @@ async def _handle_ws_message(self, data: Dict[str, Any]) -> None: integration_id = data.get("id", "") await self._handle_integration_connect_interactive(integration_id) + elif msg_type == "integration_connect_cancel": + integration_id = data.get("id", "") + await self._handle_integration_connect_cancel(integration_id) + elif msg_type == "integration_disconnect": integration_id = data.get("id", "") account_id = data.get("account_id") await self._handle_integration_disconnect(integration_id, account_id) + # Jira settings handlers + elif msg_type == "jira_get_settings": + await self._handle_jira_get_settings() + + elif msg_type == "jira_update_settings": + watch_tag = data.get("watch_tag") + watch_labels = data.get("watch_labels") + await self._handle_jira_update_settings(watch_tag=watch_tag, watch_labels=watch_labels) + + # GitHub settings handlers + elif msg_type == "github_get_settings": + await self._handle_github_get_settings() + + elif msg_type == "github_update_settings": + watch_tag = data.get("watch_tag") + watch_repos = data.get("watch_repos") + await self._handle_github_update_settings(watch_tag=watch_tag, watch_repos=watch_repos) + # WhatsApp QR code flow handlers elif msg_type == "whatsapp_start_qr": await self._handle_whatsapp_start_qr() @@ -1200,6 +1384,23 @@ async def _handle_ws_message(self, data: Dict[str, Any]) -> None: elif msg_type == "onboarding_back": await self._handle_onboarding_back() + # Local LLM (Ollama) helpers + elif msg_type == "local_llm_check": + await self._handle_local_llm_check() + elif msg_type == "local_llm_test": + url = data.get("url", "http://localhost:11434") + await self._handle_local_llm_test(url) + elif msg_type == "local_llm_install": + await self._handle_local_llm_install() + elif msg_type == "local_llm_start": + await self._handle_local_llm_start() + elif msg_type == "local_llm_suggested_models": + await self._handle_local_llm_suggested_models() + elif msg_type == "local_llm_pull_model": + model = data.get("model", "") + base_url = data.get("baseUrl") + await self._handle_local_llm_pull_model(model, base_url) + async def _handle_dashboard_metrics_filter(self, period: str) -> None: """Handle filtered metrics request for specific time period.""" try: @@ -1278,6 +1479,7 @@ async def _handle_onboarding_step_get(self) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1314,8 +1516,25 @@ async def _handle_onboarding_step_submit(self, value: Any) -> None: step = controller.get_current_step() if step.name == "api_key": provider = controller.get_collected_data().get("provider", "openai") - # Remote/Ollama provider doesn't require API key validation - if provider != "remote" and value: + if provider == "remote": + # Test Ollama connection with the submitted URL + ollama_url = (value or "http://localhost:11434").strip() + from app.ui_layer.local_llm_setup import test_ollama_connection_sync + test_result = test_ollama_connection_sync(ollama_url) + if not test_result.get("success"): + err = test_result.get("error", "Cannot reach Ollama") + await self._broadcast({ + "type": "onboarding_submit", + "data": { + "success": False, + "error": f"Ollama connection failed: {err}", + "index": controller.current_step_index, + }, + }) + return + # Normalise the value to the URL that actually worked + value = ollama_url + elif value: test_result = test_connection( provider=provider, api_key=value, @@ -1380,6 +1599,7 @@ async def _handle_onboarding_step_submit(self, value: Any) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1454,6 +1674,7 @@ async def _handle_onboarding_skip(self) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1509,6 +1730,7 @@ async def _handle_onboarding_back(self) -> None: for opt in options ], "default": controller.get_step_default(), + "provider": getattr(step, "provider", None), }, }, }) @@ -1522,6 +1744,124 @@ async def _handle_onboarding_back(self) -> None: }, }) + # ── Local LLM (Ollama) handlers ────────────────────────────────────────── + + async def _handle_local_llm_check(self) -> None: + """Return Ollama installation and runtime status.""" + try: + from app.ui_layer.local_llm_setup import get_ollama_status + status = get_ollama_status() + await self._broadcast({ + "type": "local_llm_check", + "data": {"success": True, **status}, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error checking status: {e}") + await self._broadcast({ + "type": "local_llm_check", + "data": {"success": False, "error": str(e)}, + }) + + async def _handle_local_llm_test(self, url: str) -> None: + """Test an HTTP connection to a running Ollama instance.""" + try: + from app.ui_layer.local_llm_setup import test_ollama_connection_sync + result = test_ollama_connection_sync(url) + await self._broadcast({ + "type": "local_llm_test", + "data": result, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error testing connection: {e}") + await self._broadcast({ + "type": "local_llm_test", + "data": {"success": False, "error": str(e)}, + }) + + async def _handle_local_llm_install(self) -> None: + """Install Ollama, streaming progress back to the client.""" + async def progress_callback(msg: str) -> None: + await self._broadcast({ + "type": "local_llm_install_progress", + "data": {"message": msg}, + }) + + try: + from app.ui_layer.local_llm_setup import install_ollama + result = await install_ollama(progress_callback) + await self._broadcast({ + "type": "local_llm_install", + "data": result, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error installing: {e}") + await self._broadcast({ + "type": "local_llm_install", + "data": {"success": False, "error": str(e)}, + }) + + async def _handle_local_llm_start(self) -> None: + """Start the Ollama server.""" + try: + from app.ui_layer.local_llm_setup import start_ollama + result = await start_ollama() + await self._broadcast({ + "type": "local_llm_start", + "data": result, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error starting Ollama: {e}") + await self._broadcast({ + "type": "local_llm_start", + "data": {"success": False, "error": str(e)}, + }) + + async def _handle_local_llm_suggested_models(self) -> None: + """Return the list of suggested Ollama models.""" + from app.ui_layer.local_llm_setup import SUGGESTED_MODELS + await self._broadcast({ + "type": "local_llm_suggested_models", + "data": {"models": SUGGESTED_MODELS}, + }) + + async def _handle_local_llm_pull_model(self, model: str, base_url: str | None = None) -> None: + """Pull an Ollama model, streaming progress back to the client.""" + if not model: + await self._broadcast({ + "type": "local_llm_pull_model", + "data": {"success": False, "error": "No model specified"}, + }) + return + + # Resolve base URL: explicit param > stored settings > default + if not base_url: + try: + from app.ui_layer.settings.model_settings import get_model_settings + settings_data = get_model_settings() + base_url = settings_data.get("base_urls", {}).get("remote") + except Exception: + pass + + async def progress_callback(data: dict) -> None: + await self._broadcast({ + "type": "local_llm_pull_progress", + "data": data, + }) + + try: + from app.ui_layer.local_llm_setup import pull_ollama_model + result = await pull_ollama_model(model, progress_callback, base_url=base_url) + await self._broadcast({ + "type": "local_llm_pull_model", + "data": result, + }) + except Exception as e: + logger.error(f"[LOCAL_LLM] Error pulling model {model}: {e}") + await self._broadcast({ + "type": "local_llm_pull_model", + "data": {"success": False, "error": str(e)}, + }) + async def _handle_task_cancel(self, task_id: str) -> None: """Cancel a running task.""" try: @@ -1843,7 +2183,7 @@ async def _handle_proactive_tasks_get(self, frequency: str = None) -> None: result = get_recurring_tasks( proactive_manager, frequency=frequency, - enabled_only=False + enabled_only=False, ) if result.get("success"): @@ -1862,6 +2202,7 @@ async def _handle_proactive_tasks_get(self, frequency: str = None) -> None: "day": task.get("day"), "runCount": task.get("run_count", 0), "lastRun": task.get("last_executed"), + "nextRun": task.get("next_run"), "outcomeHistory": task.get("outcome_history", []), } tasks_data.append(task_dict) @@ -2482,6 +2823,20 @@ async def _handle_model_validate_save(self, data: Dict[str, Any]) -> None: }, }) + async def _handle_ollama_models_get(self, base_url: Optional[str] = None) -> None: + """Fetch available models from Ollama and broadcast to frontend.""" + try: + if not base_url: + settings_data = get_model_settings() + base_url = settings_data.get("base_urls", {}).get("remote") + result = get_ollama_models(base_url=base_url) + await self._broadcast({"type": "ollama_models_get", "data": result}) + except Exception as e: + await self._broadcast({ + "type": "ollama_models_get", + "data": {"success": False, "models": [], "error": str(e)}, + }) + # ───────────────────────────────────────────────────────────────────── # MCP Settings Handlers # ───────────────────────────────────────────────────────────────────── @@ -3011,7 +3366,17 @@ async def _handle_integration_connect_token( }) async def _handle_integration_connect_oauth(self, integration_id: str) -> None: - """Start OAuth flow for an integration.""" + """Start OAuth flow for an integration (non-blocking).""" + # Cancel any existing OAuth task for this integration + if integration_id in self._oauth_tasks: + self._oauth_tasks[integration_id].cancel() + + # Run OAuth in background task so WebSocket message loop stays responsive + task = asyncio.create_task(self._run_oauth_flow(integration_id)) + self._oauth_tasks[integration_id] = task + + async def _run_oauth_flow(self, integration_id: str) -> None: + """Execute OAuth flow and broadcast result (runs as background task).""" try: success, message = await connect_integration_oauth(integration_id) await self._broadcast({ @@ -3025,6 +3390,16 @@ async def _handle_integration_connect_oauth(self, integration_id: str) -> None: # Refresh the list on success (listener is started by connect_integration_oauth) if success: await self._handle_integration_list() + except asyncio.CancelledError: + # OAuth was cancelled by user closing the modal + await self._broadcast({ + "type": "integration_connect_result", + "data": { + "success": False, + "message": "OAuth cancelled", + "id": integration_id, + }, + }) except Exception as e: await self._broadcast({ "type": "integration_connect_result", @@ -3034,9 +3409,21 @@ async def _handle_integration_connect_oauth(self, integration_id: str) -> None: "id": integration_id, }, }) + finally: + self._oauth_tasks.pop(integration_id, None) async def _handle_integration_connect_interactive(self, integration_id: str) -> None: - """Connect an integration using interactive flow (e.g. Telegram QR login).""" + """Connect an integration using interactive flow (non-blocking).""" + # Cancel any existing interactive task for this integration + if integration_id in self._oauth_tasks: + self._oauth_tasks[integration_id].cancel() + + # Run interactive flow in background task so WebSocket message loop stays responsive + task = asyncio.create_task(self._run_interactive_flow(integration_id)) + self._oauth_tasks[integration_id] = task + + async def _run_interactive_flow(self, integration_id: str) -> None: + """Execute interactive flow and broadcast result (runs as background task).""" try: success, message = await connect_integration_interactive(integration_id) await self._broadcast({ @@ -3050,6 +3437,16 @@ async def _handle_integration_connect_interactive(self, integration_id: str) -> # Refresh the list on success (listener is started by connect_integration_interactive) if success: await self._handle_integration_list() + except asyncio.CancelledError: + # Interactive flow was cancelled by user closing the modal + await self._broadcast({ + "type": "integration_connect_result", + "data": { + "success": False, + "message": "Connection cancelled", + "id": integration_id, + }, + }) except Exception as e: await self._broadcast({ "type": "integration_connect_result", @@ -3059,6 +3456,14 @@ async def _handle_integration_connect_interactive(self, integration_id: str) -> "id": integration_id, }, }) + finally: + self._oauth_tasks.pop(integration_id, None) + + async def _handle_integration_connect_cancel(self, integration_id: str) -> None: + """Cancel an in-progress OAuth/interactive flow.""" + if integration_id in self._oauth_tasks: + self._oauth_tasks[integration_id].cancel() + # Result will be broadcast by the cancelled task's CancelledError handler async def _handle_integration_disconnect( self, integration_id: str, account_id: Optional[str] = None @@ -3087,6 +3492,109 @@ async def _handle_integration_disconnect( }, }) + # ===================== + # Jira Settings + # ===================== + + async def _handle_jira_get_settings(self) -> None: + """Get current Jira watch tag and labels.""" + try: + from app.external_comms.credentials import has_credential, load_credential + from app.external_comms.platforms.jira import JiraCredential + if not has_credential("jira.json"): + await self._broadcast({"type": "jira_settings", "data": {"success": False, "error": "Not connected"}}) + return + cred = load_credential("jira.json", JiraCredential) + await self._broadcast({ + "type": "jira_settings", + "data": { + "success": True, + "watch_tag": cred.watch_tag if cred else "", + "watch_labels": cred.watch_labels if cred else [], + }, + }) + except Exception as e: + await self._broadcast({"type": "jira_settings", "data": {"success": False, "error": str(e)}}) + + async def _handle_jira_update_settings(self, watch_tag=None, watch_labels=None) -> None: + """Update Jira watch tag and/or labels.""" + try: + from app.external_comms.platforms.jira import JiraClient + client = JiraClient() + if not client.has_credentials(): + await self._broadcast({"type": "jira_settings_result", "data": {"success": False, "error": "Not connected"}}) + return + if watch_tag is not None: + client.set_watch_tag(watch_tag) + if watch_labels is not None: + if isinstance(watch_labels, str): + watch_labels = [l.strip() for l in watch_labels.split(",") if l.strip()] + client.set_watch_labels(watch_labels) + # Return updated settings + cred = client._load() + await self._broadcast({ + "type": "jira_settings_result", + "data": { + "success": True, + "watch_tag": cred.watch_tag, + "watch_labels": cred.watch_labels, + "message": "Jira settings updated", + }, + }) + except Exception as e: + await self._broadcast({"type": "jira_settings_result", "data": {"success": False, "error": str(e)}}) + + # ===================== + # GitHub Settings + # ===================== + + async def _handle_github_get_settings(self) -> None: + """Get current GitHub watch tag and repos.""" + try: + from app.external_comms.credentials import has_credential, load_credential + from app.external_comms.platforms.github import GitHubCredential + if not has_credential("github.json"): + await self._broadcast({"type": "github_settings", "data": {"success": False, "error": "Not connected"}}) + return + cred = load_credential("github.json", GitHubCredential) + await self._broadcast({ + "type": "github_settings", + "data": { + "success": True, + "watch_tag": cred.watch_tag if cred else "", + "watch_repos": cred.watch_repos if cred else [], + }, + }) + except Exception as e: + await self._broadcast({"type": "github_settings", "data": {"success": False, "error": str(e)}}) + + async def _handle_github_update_settings(self, watch_tag=None, watch_repos=None) -> None: + """Update GitHub watch tag and/or repos.""" + try: + from app.external_comms.platforms.github import GitHubClient + client = GitHubClient() + if not client.has_credentials(): + await self._broadcast({"type": "github_settings_result", "data": {"success": False, "error": "Not connected"}}) + return + if watch_tag is not None: + client.set_watch_tag(watch_tag) + if watch_repos is not None: + if isinstance(watch_repos, str): + watch_repos = [r.strip() for r in watch_repos.split(",") if r.strip()] + client.set_watch_repos(watch_repos) + cred = client._load() + await self._broadcast({ + "type": "github_settings_result", + "data": { + "success": True, + "watch_tag": cred.watch_tag, + "watch_repos": cred.watch_repos, + "message": "GitHub settings updated", + }, + }) + except Exception as e: + await self._broadcast({"type": "github_settings_result", "data": {"success": False, "error": str(e)}}) + # ===================== # WhatsApp QR Code Flow # ===================== @@ -3169,6 +3677,24 @@ async def _broadcast(self, message: Dict[str, Any]) -> None: # Clean up disconnected clients self._ws_clients -= disconnected + async def _broadcast_error_to_chat(self, error_message: str) -> None: + """Broadcast an error message to the chat panel for debugging.""" + import time + try: + await self._broadcast({ + "type": "chat_message", + "data": { + "sender": "System", + "content": f"[DEBUG ERROR] {error_message}", + "style": "error", + "timestamp": time.time(), + "messageId": f"error:{time.time()}", + }, + }) + except Exception: + # If broadcast fails, at least print to console + print(f"[BROWSER ADAPTER] Failed to broadcast error: {error_message}") + async def _broadcast_metrics_loop(self) -> None: """Periodically broadcast dashboard metrics to connected clients.""" while self._running: @@ -3220,8 +3746,10 @@ def _get_file_info(self, path: Path) -> Dict[str, Any]: "modified": int(stat.st_mtime * 1000), # milliseconds for JS } - async def _handle_file_list(self, directory: str) -> None: - """List files in a directory within the workspace.""" + async def _handle_file_list( + self, directory: str, offset: int = 0, limit: int = 50, search: str = "" + ) -> None: + """List files in a directory within the workspace with pagination and search.""" try: workspace = Path(AGENT_WORKSPACE_ROOT).resolve() @@ -3240,15 +3768,28 @@ async def _handle_file_list(self, directory: str) -> None: if not target.is_dir(): raise ValueError(f"Path is not a directory: {directory}") - files = [] - for item in sorted(target.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())): - files.append(self._get_file_info(item)) + # Collect and sort all files + all_files = sorted(target.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())) + + # Apply search filter + if search: + search_lower = search.lower() + all_files = [f for f in all_files if search_lower in f.name.lower()] + + total = len(all_files) + + # Apply pagination + paginated = all_files[offset:offset + limit] + files = [self._get_file_info(item) for item in paginated] await self._broadcast({ "type": "file_list", "data": { "directory": directory, "files": files, + "total": total, + "hasMore": offset + limit < total, + "offset": offset, "success": True, }, }) @@ -3258,6 +3799,9 @@ async def _handle_file_list(self, directory: str) -> None: "data": { "directory": directory, "files": [], + "total": 0, + "hasMore": False, + "offset": 0, "success": False, "error": str(e), }, @@ -3625,8 +4169,105 @@ async def _handle_file_download(self, file_path: str) -> None: }, }) - async def _handle_chat_message_with_attachments(self, content: str, attachments: List[Dict[str, Any]]) -> None: - """Handle user chat message with attachments.""" + async def _handle_chat_history(self, before_timestamp: float, limit: int = 50) -> None: + """Load older chat messages for infinite scroll.""" + try: + older_messages = self._chat.get_messages_before(before_timestamp, limit=limit) + total = self._chat.get_total_count() + + messages_data = [] + for m in older_messages: + msg_data = { + "sender": m.sender, + "content": m.content, + "style": m.style, + "timestamp": m.timestamp, + "messageId": m.message_id, + } + if m.attachments: + msg_data["attachments"] = [ + { + "name": att.name, + "path": att.path, + "type": att.type, + "size": att.size, + "url": att.url, + } + for att in m.attachments + ] + if m.task_session_id: + msg_data["taskSessionId"] = m.task_session_id + messages_data.append(msg_data) + + await self._broadcast({ + "type": "chat_history", + "data": { + "messages": messages_data, + "hasMore": len(older_messages) == limit, + "total": total, + }, + }) + except Exception as e: + await self._broadcast({ + "type": "chat_history", + "data": { + "messages": [], + "hasMore": False, + "total": 0, + "error": str(e), + }, + }) + + async def _handle_action_history(self, before_timestamp: float, limit: int = 15) -> None: + """Load older tasks (and their actions) for pagination.""" + try: + # before_timestamp is in milliseconds from frontend, convert to seconds + before_ts_seconds = before_timestamp / 1000.0 + older_items = self._action_panel.get_tasks_before(before_ts_seconds, task_limit=limit) + + # Count how many tasks were returned to determine hasMore + task_count = sum(1 for a in older_items if a.item_type == 'task') + + actions_data = [ + { + "id": a.id, + "name": a.name, + "status": a.status, + "itemType": a.item_type, + "parentId": a.parent_id, + "createdAt": int(a.created_at * 1000), + "duration": a.duration, + "input": a.input_data, + "output": a.output_data, + "error": a.error_message, + } + for a in older_items + ] + + await self._broadcast({ + "type": "action_history", + "data": { + "actions": actions_data, + "hasMore": task_count == limit, + }, + }) + except Exception as e: + await self._broadcast({ + "type": "action_history", + "data": { + "actions": [], + "hasMore": False, + "error": str(e), + }, + }) + + async def _handle_chat_message_with_attachments( + self, + content: str, + attachments: List[Dict[str, Any]], + reply_context: Optional[Dict[str, Any]] = None + ) -> None: + """Handle user chat message with attachments and optional reply context.""" import uuid from app.ui_layer.state.ui_state import AgentStateType from app.ui_layer.events import UIEvent, UIEventType @@ -3691,6 +4332,11 @@ async def _handle_chat_message_with_attachments(self, content: str, attachments: # (This is what the agent sees in the event stream - includes file paths) agent_context = content + attachment_note + # Add reply context note (similar to attachment_note pattern) + if reply_context and reply_context.get("originalMessage"): + reply_note = f"\n\n[REPLYING TO PREVIOUS AGENT MESSAGE]:\n{reply_context['originalMessage']}" + agent_context = agent_context + reply_note + if not agent_context.strip(): return @@ -3716,6 +4362,10 @@ async def _handle_chat_message_with_attachments(self, content: str, attachments: "sender": {"id": self._adapter_id or "user", "type": "user"}, "gui_mode": self._controller._state_store.state.gui_mode, } + # Include target session ID if replying to a specific session + if reply_context and reply_context.get("sessionId"): + payload["target_session_id"] = reply_context["sessionId"] + await self._controller._agent._handle_chat_message(payload) except Exception as e: @@ -3960,6 +4610,7 @@ async def send_message_with_attachments( file_paths: list, sender: Optional[str] = None, style: str = "agent", + session_id: Optional[str] = None, ) -> Dict[str, Any]: """ Send a chat message with one or more attachments from the agent. @@ -3972,6 +4623,7 @@ async def send_message_with_attachments( file_paths: List of absolute paths or paths relative to workspace sender: Message sender (default: uses agent name from onboarding) style: Message style (default: "agent") + session_id: Optional task/session ID for multi-task isolation. Returns: Dict with 'success' (bool), 'files_sent' (int), and optionally 'errors' (list of str) @@ -4000,6 +4652,7 @@ async def send_message_with_attachments( content=message, style=style, attachments=attachments, + task_session_id=session_id, ) await self._chat.append_message(chat_message) @@ -4073,6 +4726,7 @@ def _get_initial_state(self) -> Dict[str, Any]: } for att in m.attachments ]} if m.attachments else {}), + **({"taskSessionId": m.task_session_id} if m.task_session_id else {}), } for m in self._chat.get_messages() ], diff --git a/app/ui_layer/browser/frontend/package-lock.json b/app/ui_layer/browser/frontend/package-lock.json index 57a5f62b..a07a2ab8 100644 --- a/app/ui_layer/browser/frontend/package-lock.json +++ b/app/ui_layer/browser/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "craftbot-frontend", "version": "0.1.0", "dependencies": { + "@tanstack/react-virtual": "^3.13.23", "lucide-react": "^0.344.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -60,7 +61,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -223,23 +223,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -1326,6 +1326,33 @@ "win32" ] }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1372,9 +1399,9 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "license": "MIT", "dependencies": { "@types/ms": "*" @@ -1430,7 +1457,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1492,7 +1518,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -1679,7 +1704,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1775,9 +1799,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1830,7 +1854,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1856,9 +1879,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "dev": true, "funding": [ { @@ -2094,9 +2117,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", "dev": true, "license": "ISC" }, @@ -2169,7 +2192,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2497,9 +2519,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4211,7 +4233,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4224,7 +4245,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4721,7 +4741,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4892,7 +4911,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/app/ui_layer/browser/frontend/package.json b/app/ui_layer/browser/frontend/package.json index 84def27c..6bb611fb 100644 --- a/app/ui_layer/browser/frontend/package.json +++ b/app/ui_layer/browser/frontend/package.json @@ -10,6 +10,7 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@tanstack/react-virtual": "^3.13.23", "lucide-react": "^0.344.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/app/ui_layer/browser/frontend/src/components/layout/TopBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/TopBar.tsx index 4cac6cb1..701ac879 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/TopBar.tsx +++ b/app/ui_layer/browser/frontend/src/components/layout/TopBar.tsx @@ -4,6 +4,7 @@ import { IconButton } from '../ui' import { useTheme } from '../../contexts/ThemeContext' import { useWebSocket } from '../../contexts/WebSocketContext' import { StatusIndicator } from '../ui/StatusIndicator' +import { useDerivedAgentStatus } from '../../hooks' import styles from './TopBar.module.css' // Simple Discord icon component since lucide-react doesn't have it @@ -18,7 +19,14 @@ function DiscordIcon() { export function TopBar() { const { theme, toggleTheme } = useTheme() - const { connected, status } = useWebSocket() + const { connected, actions, messages } = useWebSocket() + + // Derive agent status from actions and messages + const derivedStatus = useDerivedAgentStatus({ + actions, + messages, + connected, + }) return (
@@ -32,12 +40,12 @@ export function TopBar() {
- {connected ? status.message : 'Disconnected'} + {derivedStatus.message}
diff --git a/app/ui_layer/browser/frontend/src/components/ui/Button.module.css b/app/ui_layer/browser/frontend/src/components/ui/Button.module.css index 4b0223ae..2240353a 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/Button.module.css +++ b/app/ui_layer/browser/frontend/src/components/ui/Button.module.css @@ -109,7 +109,9 @@ } .label { - display: inline-block; + display: inline-flex; + align-items: center; + gap: var(--space-2); } .spinner { diff --git a/app/ui_layer/browser/frontend/src/components/ui/MarkdownContent.tsx b/app/ui_layer/browser/frontend/src/components/ui/MarkdownContent.tsx index 9844afc5..5b22fc7b 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/MarkdownContent.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/MarkdownContent.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { memo } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' @@ -9,7 +9,7 @@ interface MarkdownContentProps { className?: string } -export function MarkdownContent({ content, className = '' }: MarkdownContentProps) { +export const MarkdownContent = memo(function MarkdownContent({ content, className = '' }: MarkdownContentProps) { return (
@@ -17,4 +17,4 @@ export function MarkdownContent({ content, className = '' }: MarkdownContentProp
) -} +}) diff --git a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css index 602c9128..0be8b207 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css +++ b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css @@ -29,11 +29,14 @@ } .pending, -.waiting, .idle { color: var(--color-gray-500); } +.waiting { + color: #3b82f6; +} + /* Spinning animation for loader icon */ .spinning { animation: spin 1s linear infinite; @@ -82,7 +85,7 @@ .dot_working, .dot_thinking, .dot_running { - background: var(#ff4f18, #ff9878); + background: #ff4f18; } .dot_error, @@ -90,11 +93,14 @@ background: var(--color-error); } -.dot_pending, -.dot_waiting { +.dot_pending { background: var(--color-gray-500); } +.dot_waiting { + background: #3b82f6; +} + /* Pulse animation for active agent states */ .pulse { animation: pulse 1.5s ease-in-out infinite; diff --git a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.tsx b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.tsx index db0d665a..27ca201e 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CheckCircle, XCircle, Loader, Clock } from 'lucide-react' +import { CheckCircle, XCircle, Loader, Clock, MessageCircle } from 'lucide-react' import styles from './StatusIndicator.module.css' import type { ActionStatus, AgentState } from '../../types' @@ -55,8 +55,9 @@ export function StatusIndicator({ case 'thinking': case 'working': return - case 'pending': case 'waiting': + return + case 'pending': case 'idle': default: return diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 37c64425..a18445af 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useEffect, useRef, useState, useCallback, ReactNode } from 'react' -import type { ChatMessage, ActionItem, AgentStatus, InitialState, WSMessage, DashboardMetrics, TaskCancelResponse, FilteredDashboardMetrics, MetricsTimePeriod, OnboardingStep, OnboardingStepResponse, OnboardingSubmitResponse, OnboardingCompleteResponse } from '../types' +import type { ChatMessage, ActionItem, AgentStatus, InitialState, WSMessage, DashboardMetrics, TaskCancelResponse, FilteredDashboardMetrics, MetricsTimePeriod, OnboardingStep, OnboardingStepResponse, OnboardingSubmitResponse, OnboardingCompleteResponse, LocalLLMState, LocalLLMCheckResponse, LocalLLMTestResponse, LocalLLMInstallResponse, LocalLLMProgressResponse, LocalLLMPullProgressResponse, SuggestedModel } from '../types' import { getWsUrl } from '../utils/connection' // Pending attachment type for upload @@ -10,6 +10,20 @@ interface PendingAttachment { content: string // base64 } +// Reply target for reply-to-chat/task feature +interface ReplyTarget { + type: 'chat' | 'task' + sessionId?: string // May be undefined for old messages without session tracking + displayName: string // Truncated preview for UI display + originalContent: string // Full content for agent context +} + +// Reply context sent with message +interface ReplyContext { + sessionId?: string + originalMessage: string +} + interface WebSocketState { connected: boolean messages: ChatMessage[] @@ -27,10 +41,22 @@ interface WebSocketState { onboardingStep: OnboardingStep | null onboardingError: string | null onboardingLoading: boolean + // Unread message tracking + lastSeenMessageId: string | null + // Reply state for reply-to-chat/task feature + replyTarget: ReplyTarget | null + // Chat pagination + hasMoreMessages: boolean + loadingOlderMessages: boolean + // Action pagination + hasMoreActions: boolean + loadingOlderActions: boolean + // Local LLM (Ollama) state + localLLM: LocalLLMState } interface WebSocketContextType extends WebSocketState { - sendMessage: (content: string, attachments?: PendingAttachment[]) => void + sendMessage: (content: string, attachments?: PendingAttachment[], replyContext?: ReplyContext) => void sendCommand: (command: string) => void clearMessages: () => void cancelTask: (taskId: string) => void @@ -42,6 +68,31 @@ interface WebSocketContextType extends WebSocketState { submitOnboardingStep: (value: string | string[]) => void skipOnboardingStep: () => void goBackOnboardingStep: () => void + // Unread message tracking + markMessagesAsSeen: () => void + // Reply-to-chat/task methods + setReplyTarget: (target: ReplyTarget) => void + clearReplyTarget: () => void + // Chat pagination + loadOlderMessages: () => void + // Action pagination + loadOlderActions: () => void + // Local LLM (Ollama) methods + checkLocalLLM: () => void + testLocalLLMConnection: (url: string) => void + installLocalLLM: () => void + startLocalLLM: () => void + requestSuggestedModels: () => void + pullOllamaModel: (model: string) => void +} + +// Initialize lastSeenMessageId from localStorage +const getInitialLastSeenMessageId = (): string | null => { + try { + return localStorage.getItem('lastSeenMessageId') + } catch { + return null + } } const defaultState: WebSocketState = { @@ -71,6 +122,25 @@ const defaultState: WebSocketState = { onboardingStep: null, onboardingError: null, onboardingLoading: false, + // Unread message tracking + lastSeenMessageId: getInitialLastSeenMessageId(), + // Reply state + replyTarget: null, + // Chat pagination + hasMoreMessages: true, + loadingOlderMessages: false, + // Action pagination + hasMoreActions: true, + loadingOlderActions: false, + // Local LLM (Ollama) state + localLLM: { + phase: 'idle', + defaultUrl: 'http://localhost:11434', + installProgress: [], + pullProgress: [], + pullBytes: null, + suggestedModels: [], + }, } const WebSocketContext = createContext(undefined) @@ -122,8 +192,8 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } } - ws.onclose = () => { - console.log('[WS] Disconnected, reconnectCount =', reconnectCountRef.current) + ws.onclose = (event) => { + console.log('[WS] Disconnected, code:', event.code, 'reason:', event.reason, 'wasClean:', event.wasClean, 'reconnectCount:', reconnectCountRef.current) isConnectingRef.current = false setState(prev => ({ ...prev, @@ -174,10 +244,12 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { switch (msg.type) { case 'init': { const data = msg.data as unknown as InitialState + const initMessages = data.messages || [] + const initActions = data.actions || [] setState(prev => ({ ...prev, - messages: data.messages || [], - actions: data.actions || [], + messages: initMessages, + actions: initActions, status: { state: data.agentState || 'idle', message: data.status || 'Ready', @@ -188,6 +260,8 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { dashboardMetrics: data.dashboardMetrics || null, needsHardOnboarding: data.needsHardOnboarding || false, agentName: data.agentName || 'Agent', + hasMoreMessages: initMessages.length >= 50, + hasMoreActions: initActions.filter((a: ActionItem) => a.itemType === 'task').length >= 15, })) break } @@ -201,10 +275,32 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { break } + case 'chat_history': { + const data = msg.data as unknown as { messages: ChatMessage[]; hasMore: boolean } + setState(prev => ({ + ...prev, + messages: [...(data.messages || []), ...prev.messages], + hasMoreMessages: data.hasMore, + loadingOlderMessages: false, + })) + break + } + case 'chat_clear': - setState(prev => ({ ...prev, messages: [] })) + setState(prev => ({ ...prev, messages: [], hasMoreMessages: false })) break + case 'action_history': { + const data = msg.data as unknown as { actions: ActionItem[]; hasMore: boolean } + setState(prev => ({ + ...prev, + actions: [...(data.actions || []), ...prev.actions], + hasMoreActions: data.hasMore, + loadingOlderActions: false, + })) + break + } + case 'action_add': { const action = msg.data as unknown as ActionItem setState(prev => { @@ -428,6 +524,158 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } break } + + // ── Local LLM (Ollama) ─────────────────────────────────────────────── + case 'local_llm_check': { + const r = msg.data as unknown as LocalLLMCheckResponse + // Phases that must not be overridden by a background check result + const BUSY_PHASES: LocalLLMState['phase'][] = ['installing', 'starting', 'pulling_model'] + if (!r.success) { + setState(prev => { + if (BUSY_PHASES.includes(prev.localLLM.phase)) return prev + return { ...prev, localLLM: { ...prev.localLLM, phase: 'error', error: r.error } } + }) + break + } + let phase: LocalLLMState['phase'] + if (r.running) { + phase = 'running' + } else if (r.installed) { + phase = 'not_running' + } else { + phase = 'not_installed' + } + setState(prev => { + if (BUSY_PHASES.includes(prev.localLLM.phase)) return prev + return { + ...prev, + localLLM: { + ...prev.localLLM, + phase, + version: r.version, + defaultUrl: r.default_url || 'http://localhost:11434', + error: undefined, + testResult: undefined, + }, + } + }) + break + } + + case 'local_llm_test': { + const r = msg.data as unknown as LocalLLMTestResponse + if (r.success && (!r.models || r.models.length === 0)) { + // Connected but no models — ask user to pick one + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + phase: 'selecting_model', + testResult: { success: r.success, message: r.message, error: r.error, models: r.models }, + }, + })) + wsRef.current?.send(JSON.stringify({ type: 'local_llm_suggested_models' })) + } else { + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + phase: r.success ? 'connected' : prev.localLLM.phase, + testResult: { success: r.success, message: r.message, error: r.error, models: r.models }, + }, + })) + } + break + } + + case 'local_llm_install_progress': { + const r = msg.data as unknown as LocalLLMProgressResponse + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + installProgress: [...prev.localLLM.installProgress, r.message], + }, + })) + break + } + + case 'local_llm_install': { + const r = msg.data as unknown as LocalLLMInstallResponse + if (r.success) { + // Trigger a status check instead of assuming 'not_running' — + // the installer may have auto-launched Ollama already + setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, phase: 'checking', installProgress: [] } })) + wsRef.current?.send(JSON.stringify({ type: 'local_llm_check' })) + } else { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'error', error: r.error ?? 'Installation failed' }, + })) + } + break + } + + case 'local_llm_start': { + const r = msg.data as unknown as LocalLLMInstallResponse + setState(prev => ({ + ...prev, + localLLM: { + ...prev.localLLM, + phase: r.success ? 'running' : 'error', + error: r.success ? undefined : (r.error ?? 'Failed to start Ollama'), + testResult: undefined, + }, + })) + break + } + + case 'local_llm_suggested_models': { + const r = msg.data as unknown as { models: SuggestedModel[] } + setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, suggestedModels: r.models } })) + break + } + + case 'local_llm_pull_progress': { + const r = msg.data as unknown as LocalLLMPullProgressResponse + setState(prev => { + // Only append to the log for non-byte-progress status lines + const isDownloading = r.total > 0 + const newLog = isDownloading + ? prev.localLLM.pullProgress // don't spam log with repeated byte updates + : r.message && !prev.localLLM.pullProgress.includes(r.message) + ? [...prev.localLLM.pullProgress, r.message] + : prev.localLLM.pullProgress + return { + ...prev, + localLLM: { + ...prev.localLLM, + pullProgress: newLog, + pullBytes: isDownloading + ? { completed: r.completed, total: r.total, percent: r.percent } + : prev.localLLM.pullBytes, + }, + } + }) + break + } + + case 'local_llm_pull_model': { + const r = msg.data as unknown as LocalLLMInstallResponse & { model?: string } + if (r.success) { + // Re-test to refresh model count and advance to 'connected' + setState(prev => { + wsRef.current?.send(JSON.stringify({ type: 'local_llm_test', url: prev.localLLM.defaultUrl })) + return { ...prev, localLLM: { ...prev.localLLM, pullProgress: [], error: undefined } } + }) + } else { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'error', error: r.error ?? 'Model download failed' }, + })) + } + break + } } }, []) @@ -447,13 +695,56 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, [connect]) - const sendMessage = useCallback((content: string, attachments?: PendingAttachment[]) => { + const loadOlderMessages = useCallback(() => { + if (!state.hasMoreMessages || state.loadingOlderMessages || state.messages.length === 0) return + if (wsRef.current?.readyState !== WebSocket.OPEN) return + + const oldestTimestamp = state.messages[0]?.timestamp + if (!oldestTimestamp) return + + setState(prev => ({ ...prev, loadingOlderMessages: true })) + wsRef.current.send(JSON.stringify({ + type: 'chat_history', + beforeTimestamp: oldestTimestamp, + limit: 50, + })) + }, [state.hasMoreMessages, state.loadingOlderMessages, state.messages]) + + const loadOlderActions = useCallback(() => { + if (!state.hasMoreActions || state.loadingOlderActions || state.actions.length === 0) return + if (wsRef.current?.readyState !== WebSocket.OPEN) return + + // Find the oldest task's createdAt (not action) for the before_timestamp + const oldestTask = state.actions.find(a => a.itemType === 'task') + const oldestCreatedAt = oldestTask?.createdAt || state.actions[0]?.createdAt + if (!oldestCreatedAt) return + + setState(prev => ({ ...prev, loadingOlderActions: true })) + wsRef.current.send(JSON.stringify({ + type: 'action_history', + beforeTimestamp: oldestCreatedAt, + limit: 15, + })) + }, [state.hasMoreActions, state.loadingOlderActions, state.actions]) + + const sendMessage = useCallback((content: string, attachments?: PendingAttachment[], replyContext?: ReplyContext) => { if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - type: 'message', - content, - attachments: attachments || [] - })) + try { + const payload = { + type: 'message', + content, + attachments: attachments || [], + replyContext: replyContext || null, + } + const payloadStr = JSON.stringify(payload) + console.log('[WebSocket] Sending message, payload size:', payloadStr.length, 'bytes, attachments:', attachments?.length || 0) + wsRef.current.send(payloadStr) + console.log('[WebSocket] Message sent successfully') + } catch (error) { + console.error('[WebSocket] Error sending message:', error) + } + } else { + console.warn('[WebSocket] Cannot send message - WebSocket not open, state:', wsRef.current?.readyState) } }, []) @@ -524,6 +815,89 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, []) + // Mark all current messages as seen + const markMessagesAsSeen = useCallback(() => { + setState(prev => { + if (prev.messages.length > 0) { + const lastId = prev.messages[prev.messages.length - 1].messageId + if (lastId && lastId !== prev.lastSeenMessageId) { + try { + localStorage.setItem('lastSeenMessageId', lastId) + } catch { + // localStorage may be unavailable + } + return { ...prev, lastSeenMessageId: lastId } + } + } + return prev + }) + }, []) + + // Set reply target for reply-to-chat/task feature + const setReplyTarget = useCallback((target: ReplyTarget) => { + setState(prev => ({ ...prev, replyTarget: target })) + }, []) + + // Clear reply target + const clearReplyTarget = useCallback(() => { + setState(prev => ({ ...prev, replyTarget: null })) + }, []) + + // Local LLM (Ollama) methods + const checkLocalLLM = useCallback(() => { + if (wsRef.current?.readyState !== WebSocket.OPEN) return + const BUSY_PHASES: LocalLLMState['phase'][] = ['installing', 'starting', 'pulling_model'] + setState(prev => { + if (BUSY_PHASES.includes(prev.localLLM.phase)) return prev // Don't interrupt active ops + return { ...prev, localLLM: { ...prev.localLLM, phase: 'checking', error: undefined } } + }) + wsRef.current.send(JSON.stringify({ type: 'local_llm_check' })) + }, []) + + const testLocalLLMConnection = useCallback((url: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'local_llm_test', url })) + } + }, []) + + const installLocalLLM = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'installing', installProgress: [], error: undefined }, + })) + wsRef.current.send(JSON.stringify({ type: 'local_llm_install' })) + } else { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'error', error: 'Not connected — please wait a moment and retry.' }, + })) + } + }, []) + + const startLocalLLM = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setState(prev => ({ ...prev, localLLM: { ...prev.localLLM, phase: 'starting', error: undefined } })) + wsRef.current.send(JSON.stringify({ type: 'local_llm_start' })) + } + }, []) + + const requestSuggestedModels = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'local_llm_suggested_models' })) + } + }, []) + + const pullOllamaModel = useCallback((model: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setState(prev => ({ + ...prev, + localLLM: { ...prev.localLLM, phase: 'pulling_model', pullProgress: [], pullBytes: null, error: undefined }, + })) + wsRef.current.send(JSON.stringify({ type: 'local_llm_pull_model', model })) + } + }, []) + return ( {children} diff --git a/app/ui_layer/browser/frontend/src/contexts/WorkspaceContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WorkspaceContext.tsx index b6c87a88..bf95659e 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WorkspaceContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WorkspaceContext.tsx @@ -24,11 +24,16 @@ interface WorkspaceState { currentDirectory: string files: FileItem[] loading: boolean + loadingMore: boolean error: string | null selectedFile: FileItem | null fileContent: string | null fileIsBinary: boolean connected: boolean + total: number + hasMore: boolean + offset: number + search: string } interface PendingOperation { @@ -42,6 +47,8 @@ interface WorkspaceContextType extends WorkspaceState { refresh: () => Promise selectFile: (file: FileItem | null) => void listDirectory: (directory: string) => Promise + loadMore: () => Promise + setSearch: (query: string) => void // File operations readFile: (path: string) => Promise @@ -56,15 +63,22 @@ interface WorkspaceContextType extends WorkspaceState { downloadFile: (path: string) => Promise } +const FILE_PAGE_SIZE = 50 + const defaultState: WorkspaceState = { currentDirectory: '', files: [], loading: false, + loadingMore: false, error: null, selectedFile: null, fileContent: null, fileIsBinary: false, connected: false, + total: 0, + hasMore: false, + offset: 0, + search: '', } // ───────────────────────────────────────────────────────────────────── @@ -99,12 +113,20 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { switch (msg.type) { case 'file_list': { const data = msg.data as unknown as FileListResponse - setState(prev => ({ - ...prev, - files: data.files || [], - loading: false, - error: data.success ? null : data.error || 'Failed to list files', - })) + setState(prev => { + // If offset > 0, append (load more). Otherwise replace (fresh load). + const isLoadMore = data.offset > 0 + return { + ...prev, + files: isLoadMore ? [...prev.files, ...(data.files || [])] : (data.files || []), + total: data.total ?? 0, + hasMore: data.hasMore ?? false, + offset: (data.offset ?? 0) + (data.files?.length ?? 0), + loading: false, + loadingMore: false, + error: data.success ? null : data.error || 'Failed to list files', + } + }) resolvePending('file_list', data) break } @@ -356,9 +378,14 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { // ───────────────────────────────────────────────────────────────────── const navigateTo = useCallback(async (directory: string) => { - setState(prev => ({ ...prev, loading: true, error: null, currentDirectory: directory })) + setState(prev => ({ + ...prev, loading: true, error: null, currentDirectory: directory, + files: [], offset: 0, hasMore: false, total: 0, search: '', + })) try { - await sendOperation('file_list', { directory }, 'file_list') + await sendOperation( + 'file_list', { directory, offset: 0, limit: FILE_PAGE_SIZE }, 'file_list' + ) } catch (error) { setState(prev => ({ ...prev, @@ -369,8 +396,46 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { }, [sendOperation]) const refresh = useCallback(async () => { - await navigateTo(state.currentDirectory) - }, [navigateTo, state.currentDirectory]) + setState(prev => ({ ...prev, loading: true, error: null, files: [], offset: 0, hasMore: false, total: 0 })) + try { + await sendOperation( + 'file_list', + { directory: state.currentDirectory, offset: 0, limit: FILE_PAGE_SIZE, search: state.search }, + 'file_list' + ) + } catch (error) { + setState(prev => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : 'Failed to refresh', + })) + } + }, [sendOperation, state.currentDirectory, state.search]) + + const loadMore = useCallback(async () => { + if (!state.hasMore || state.loadingMore) return + setState(prev => ({ ...prev, loadingMore: true })) + try { + await sendOperation( + 'file_list', + { directory: state.currentDirectory, offset: state.offset, limit: FILE_PAGE_SIZE, search: state.search }, + 'file_list' + ) + } catch (error) { + setState(prev => ({ ...prev, loadingMore: false })) + } + }, [sendOperation, state.hasMore, state.loadingMore, state.currentDirectory, state.offset, state.search]) + + const setSearch = useCallback((query: string) => { + setState(prev => ({ ...prev, search: query, loading: true, files: [], offset: 0, hasMore: false, total: 0 })) + sendOperation( + 'file_list', + { directory: state.currentDirectory, offset: 0, limit: FILE_PAGE_SIZE, search: query }, + 'file_list' + ).catch(() => { + setState(prev => ({ ...prev, loading: false })) + }) + }, [sendOperation, state.currentDirectory]) const selectFile = useCallback((file: FileItem | null) => { setState(prev => ({ @@ -506,6 +571,8 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { refresh, selectFile, listDirectory, + loadMore, + setSearch, readFile, writeFile, createFile, diff --git a/app/ui_layer/browser/frontend/src/hooks/index.ts b/app/ui_layer/browser/frontend/src/hooks/index.ts index c3c436c4..8d9a983a 100644 --- a/app/ui_layer/browser/frontend/src/hooks/index.ts +++ b/app/ui_layer/browser/frontend/src/hooks/index.ts @@ -1,2 +1,3 @@ export { useConfirmModal } from './useConfirmModal' export type { ConfirmModalState, ConfirmOptions } from './useConfirmModal' +export { useDerivedAgentStatus } from './useDerivedAgentStatus' diff --git a/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts b/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts new file mode 100644 index 00000000..052ebb10 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react' +import type { ActionItem, AgentState, AgentStatus, ChatMessage } from '../types' + +interface DerivedStatusOptions { + actions: ActionItem[] + messages: ChatMessage[] + connected: boolean +} + +/** + * Derives agent status from the actions array and messages. + * + * This is more robust than relying on separate status_update messages because: + * 1. Single source of truth - actions and messages arrays contain all state + * 2. Always in sync - computed status can never be stale + * 3. Shows meaningful info - displays actual task/action names + */ +export function useDerivedAgentStatus( + options: DerivedStatusOptions +): AgentStatus { + const { actions, messages, connected } = options + + return useMemo(() => { + // If not connected, show error state + if (!connected) { + return { + state: 'error' as AgentState, + message: 'Disconnected', + loading: false, + } + } + + // Find running tasks (top-level items) + const runningTasks = actions.filter( + a => a.itemType === 'task' && a.status === 'running' + ) + + // Find waiting tasks + const waitingTasks = actions.filter( + a => a.itemType === 'task' && a.status === 'waiting' + ) + + // Priority 1: If any task is waiting for user response + if (waitingTasks.length > 0) { + const taskName = waitingTasks[0].name + return { + state: 'waiting' as AgentState, + message: `Agent is waiting response on ${taskName}`, + loading: false, + } + } + + // Priority 2: If there are running tasks, list them + if (runningTasks.length > 0) { + const taskNames = runningTasks.map(t => t.name) + const message = taskNames.length === 1 + ? `Agent is working on ${taskNames[0]}` + : `Agent is working on ${taskNames.join(', ')}` + return { + state: 'working' as AgentState, + message, + loading: true, + } + } + + // Priority 3: If the last message is from user, agent is processing it + // (no running tasks yet means agent is still thinking/preparing) + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1] + if (lastMessage.style === 'user') { + return { + state: 'working' as AgentState, + message: 'Agent is working', + loading: true, + } + } + } + + // Default: Idle state + return { + state: 'idle' as AgentState, + message: 'Agent is idle', + loading: false, + } + }, [actions, messages, connected]) +} diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx new file mode 100644 index 00000000..0c5e26c0 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx @@ -0,0 +1,109 @@ +import React, { memo, useState, useMemo } from 'react' +import { Reply } from 'lucide-react' +import { MarkdownContent, AttachmentDisplay, IconButton } from '../../components/ui' +import type { ChatMessage as ChatMessageType } from '../../types' +import styles from './ChatPage.module.css' + +interface ChatMessageProps { + message: ChatMessageType + onOpenFile: (path: string) => void + onOpenFolder: (path: string) => void + onReply?: ( + sessionId: string | undefined, + displayName: string, + fullContent: string + ) => void +} + +// Parse reply context from message content +const REPLY_MARKER = '[REPLYING TO PREVIOUS AGENT MESSAGE]:' + +function parseReplyContext(content: string): { userMessage: string; replyContext: string | null } { + const markerIndex = content.indexOf(REPLY_MARKER) + if (markerIndex === -1) { + return { userMessage: content, replyContext: null } + } + const userMessage = content.slice(0, markerIndex).trim() + const replyContext = content.slice(markerIndex + REPLY_MARKER.length).trim() + return { userMessage, replyContext } +} + +export const ChatMessageItem = memo(function ChatMessageItem({ + message, + onOpenFile, + onOpenFolder, + onReply, +}: ChatMessageProps) { + const [isHovered, setIsHovered] = useState(false) + + // Show reply for ALL agent messages + const canReply = message.style === 'agent' && onReply + + // Parse reply context for user messages + const { userMessage, replyContext } = useMemo(() => { + if (message.style === 'user') { + return parseReplyContext(message.content) + } + return { userMessage: message.content, replyContext: null } + }, [message.content, message.style]) + + const handleReply = (e: React.MouseEvent) => { + e.stopPropagation() + if (canReply) { + // Truncate content for display preview + const displayName = message.content.length > 50 + ? message.content.slice(0, 50) + '...' + : message.content + onReply(message.taskSessionId, displayName, message.content) + } + } + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Message bubble container - for positioning reply button outside */} +
+
+
+ {message.sender} + + {new Date(message.timestamp * 1000).toLocaleTimeString()} + +
+ {/* Reply context callout - shown above user message when replying */} + {replyContext && ( +
+ +
+ )} +
+ +
+
+ {/* Reply button - positioned outside the bubble at top-right */} + {canReply && isHovered && ( + } + variant="ghost" + size="sm" + onClick={handleReply} + tooltip="Reply to this message" + className={styles.replyButtonOutside} + /> + )} +
+ {message.attachments && message.attachments.length > 0 && ( +
+ +
+ )} +
+ ) +}, (prev, next) => prev.message.messageId === next.message.messageId) diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css index b0d3e8d9..92db79d1 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css @@ -82,8 +82,10 @@ .messageWrapper { display: flex; flex-direction: column; + width: fit-content; max-width: 80%; gap: var(--space-2); + padding-bottom: var(--space-3); } .messageWrapper.userWrapper { @@ -97,9 +99,8 @@ } .messageWrapper.systemWrapper { - margin: 0 auto; - align-items: center; - max-width: 90%; + margin-right: auto; + align-items: flex-start; } .messageWrapper.errorWrapper { @@ -504,3 +505,107 @@ .dismissError:hover { opacity: 1; } + +/* ───────────────────────────────────────────────────────────────────── + Reply UI Styles + ───────────────────────────────────────────────────────────────────── */ + +/* Agent wrapper needs padding-right for the reply button */ +.agentWrapper { + padding-right: var(--space-8); +} + +/* Message bubble container - wraps bubble + reply button */ +.messageBubbleContainer { + position: relative; +} + +/* Reply button outside the bubble - positioned in the padding area */ +.replyButtonOutside { + position: absolute; + top: var(--space-1); + left: 100%; + margin-left: var(--space-1); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.messageWrapper:hover .replyButtonOutside { + opacity: 1; +} + +/* Reply bar above input - styled like pending attachments */ +.replyBar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + color: var(--text-primary); +} + +.replyText { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.replyCancel { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--text-muted); + transition: color var(--transition-fast); + flex-shrink: 0; +} + +.replyCancel:hover { + color: var(--color-error); +} + +/* Task reply button - shown on hover */ +.taskReplyBtn { + opacity: 0; + flex-shrink: 0; + color: var(--text-muted); + transition: opacity var(--transition-fast), color var(--transition-fast); +} + +.taskItem:hover .taskReplyBtn { + opacity: 1; +} + +.taskReplyBtn:hover { + color: var(--color-primary); + background: var(--color-primary-light); +} + +/* Reply context callout - shown above user message when replying */ +.replyContextCallout { + margin-bottom: var(--space-2); + padding: var(--space-2) var(--space-3); + background: rgba(0, 0, 0, 0.05); + border-left: 3px solid rgba(0, 0, 0, 0.15); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + color: inherit; + opacity: 0.85; +} + +.replyContextCallout p { + margin: 0; +} + +.replyContextCallout ul, +.replyContextCallout ol { + margin: var(--space-1) 0; + padding-left: var(--space-4); +} diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx index 732cc416..de1ee79e 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx @@ -1,7 +1,11 @@ import React, { useState, useRef, useEffect, useLayoutEffect, KeyboardEvent, useCallback, ChangeEvent, useMemo } from 'react' -import { Send, Paperclip, X, Loader2, File, AlertCircle } from 'lucide-react' +import { Send, Paperclip, X, Loader2, File, AlertCircle, Reply } from 'lucide-react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { useLocation } from 'react-router-dom' import { useWebSocket } from '../../contexts/WebSocketContext' -import { Button, IconButton, StatusIndicator, MarkdownContent, AttachmentDisplay } from '../../components/ui' +import { Button, IconButton, StatusIndicator } from '../../components/ui' +import { useDerivedAgentStatus } from '../../hooks' +import { ChatMessageItem } from './ChatMessage' import styles from './ChatPage.module.css' // Pending attachment type @@ -18,8 +22,10 @@ const MIN_PANEL_WIDTH = 200 const MAX_PANEL_WIDTH = 800 // Attachment limits +// Backend WebSocket has 100MB limit, base64 encoding adds ~33% overhead +// So raw file limit should be ~70MB to stay safely under the WebSocket limit const MAX_ATTACHMENT_COUNT = 10 -const MAX_TOTAL_SIZE_BYTES = 50 * 1024 * 1024 * 1024 // 50GB +const MAX_TOTAL_SIZE_BYTES = 70 * 1024 * 1024 // 70MB (leaves room for base64 encoding + JSON overhead) // Format file size for display const formatFileSize = (bytes: number): string => { @@ -31,14 +37,28 @@ const formatFileSize = (bytes: number): string => { } export function ChatPage() { - const { messages, actions, status, connected, sendMessage, cancelTask, cancellingTaskId, openFile, openFolder } = useWebSocket() + const { messages, actions, connected, sendMessage, cancelTask, cancellingTaskId, openFile, openFolder, lastSeenMessageId, markMessagesAsSeen, replyTarget, setReplyTarget, clearReplyTarget, loadOlderMessages, hasMoreMessages, loadingOlderMessages } = useWebSocket() + + // Derive agent status from actions and messages + const status = useDerivedAgentStatus({ + actions, + messages, + connected, + }) const [input, setInput] = useState('') const [pendingAttachments, setPendingAttachments] = useState([]) const [attachmentError, setAttachmentError] = useState(null) - const messagesEndRef = useRef(null) const inputRef = useRef(null) const fileInputRef = useRef(null) + // Virtualization refs + const parentRef = useRef(null) + const hasScrolledRef = useRef(false) + const prevMessageCountRef = useRef(0) + const prevPathRef = useRef(null) + const wasNearBottomRef = useRef(true) + const location = useLocation() + // Resizable panel state const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH) const [isResizing, setIsResizing] = useState(false) @@ -58,16 +78,100 @@ export function ChatPage() { if (totalSize > MAX_TOTAL_SIZE_BYTES) { return { valid: false, - error: `Total size (${formatFileSize(totalSize)}) exceeds 50GB limit. Please remove some files or copy large files directly to the agent workspace.` + error: `Total size (${formatFileSize(totalSize)}) exceeds 70MB limit. Please remove some files or copy large files directly to the agent workspace.` } } return { valid: true, error: null } }, [pendingAttachments]) - // Auto-scroll to bottom when new messages arrive + // Setup virtualizer for efficient message rendering + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 100, + overscan: 5, + }) + + // Find first unread message index, returns -1 if no unread messages + const getFirstUnreadIndex = useCallback(() => { + if (!lastSeenMessageId) return -1 // No history, no unread tracking + const lastSeenIdx = messages.findIndex(m => m.messageId === lastSeenMessageId) + if (lastSeenIdx === -1) { + return 0 // ID not found (stale) - treat all as unread, start from beginning + } + if (lastSeenIdx === messages.length - 1) { + return -1 // Already at end, no unread + } + return lastSeenIdx + 1 // First unread is after last seen + }, [messages, lastSeenMessageId]) + + // Check if user is scrolled near the bottom + const isNearBottom = useCallback(() => { + const container = parentRef.current + if (!container) return true + const threshold = 100 // pixels from bottom + return container.scrollHeight - container.scrollTop - container.clientHeight < threshold + }, []) + + // Track scroll position continuously so we know where user was BEFORE new messages arrive + // Also detect scroll-to-top to load older messages useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + const container = parentRef.current + if (!container) return + + const handleScroll = () => { + wasNearBottomRef.current = isNearBottom() + + // Load older messages when scrolled near top + if (container.scrollTop < 100 && hasMoreMessages && !loadingOlderMessages) { + loadOlderMessages() + } + } + + container.addEventListener('scroll', handleScroll) + return () => container.removeEventListener('scroll', handleScroll) + }, [isNearBottom, hasMoreMessages, loadingOlderMessages, loadOlderMessages]) + + // Scroll to unread messages when entering chat page, smooth scroll for new messages only if near bottom + useEffect(() => { + if (messages.length === 0) return + + const isNavigatingToChat = prevPathRef.current !== null && prevPathRef.current !== '/' && location.pathname === '/' + const isFirstLoad = prevPathRef.current === null + const isNewMessage = messages.length > prevMessageCountRef.current + const shouldScrollToUnread = (isFirstLoad || isNavigatingToChat) && !hasScrolledRef.current + + prevPathRef.current = location.pathname + prevMessageCountRef.current = messages.length + + if (shouldScrollToUnread) { + hasScrolledRef.current = true + const firstUnreadIdx = getFirstUnreadIndex() + const hasUnreadMessages = firstUnreadIdx !== -1 + // Wait for virtualizer to measure elements before scrolling + setTimeout(() => { + if (hasUnreadMessages) { + // Scroll to first unread message at the top + virtualizer.scrollToIndex(firstUnreadIdx, { align: 'start', behavior: 'auto' }) + } else { + // All messages seen - scroll to bottom + virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'auto' }) + } + markMessagesAsSeen() + }, 50) + } else if (isNewMessage && location.pathname === '/' && wasNearBottomRef.current) { + // Only auto-scroll if user WAS near the bottom before new message arrived + virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'smooth' }) + markMessagesAsSeen() + } + }, [messages.length, location.pathname, virtualizer, getFirstUnreadIndex, markMessagesAsSeen]) + + // Reset scroll flag when navigating away from chat + useEffect(() => { + if (location.pathname !== '/') { + hasScrolledRef.current = false + } + }, [location.pathname]) // Auto-resize textarea based on content const adjustTextareaHeight = useCallback(() => { @@ -117,15 +221,52 @@ export function ChatPage() { } }, [isResizing]) + // Handle reply from chat message + const handleChatReply = useCallback(( + sessionId: string | undefined, + displayName: string, + fullContent: string + ) => { + setReplyTarget({ + type: 'chat', + sessionId, + displayName, + originalContent: fullContent, + }) + inputRef.current?.focus() + }, [setReplyTarget]) + + // Handle reply from task panel + const handleTaskReply = useCallback((taskId: string, taskName: string) => { + setReplyTarget({ + type: 'task', + sessionId: taskId, + displayName: taskName, + originalContent: `Task: ${taskName}`, + }) + inputRef.current?.focus() + }, [setReplyTarget]) + const handleSend = () => { // Don't send if there are validation errors if (!attachmentValidation.valid) return if (input.trim() || pendingAttachments.length > 0) { - sendMessage(input.trim(), pendingAttachments.length > 0 ? pendingAttachments : undefined) + // Include reply context if replying to a message/task + const replyContext = replyTarget ? { + sessionId: replyTarget.sessionId, + originalMessage: replyTarget.originalContent, + } : undefined + + sendMessage( + input.trim(), + pendingAttachments.length > 0 ? pendingAttachments : undefined, + replyContext + ) setInput('') setPendingAttachments([]) setAttachmentError(null) + clearReplyTarget() // Clear reply target after sending // Reset textarea height after clearing input if (inputRef.current) { inputRef.current.style.height = 'auto' @@ -165,14 +306,14 @@ export function ChatPage() { // Check individual file size (for very large files, recommend manual copy) if (file.size > MAX_TOTAL_SIZE_BYTES) { - setAttachmentError(`File "${file.name}" (${formatFileSize(file.size)}) exceeds the 50GB limit. For very large files, please copy them directly to the agent workspace folder.`) + setAttachmentError(`File "${file.name}" (${formatFileSize(file.size)}) exceeds the 70MB limit. For very large files, please copy them directly to the agent workspace folder.`) e.target.value = '' return } // Check if adding this file would exceed total size limit if (newTotalSize + file.size > MAX_TOTAL_SIZE_BYTES) { - setAttachmentError(`Adding "${file.name}" would exceed the 50GB total size limit. Current total: ${formatFileSize(newTotalSize)}. For large files, please copy them directly to the agent workspace folder.`) + setAttachmentError(`Adding "${file.name}" would exceed the 70MB total size limit. Current total: ${formatFileSize(newTotalSize)}. For large files, please copy them directly to the agent workspace folder.`) e.target.value = '' return } @@ -235,7 +376,7 @@ export function ChatPage() {
{/* Chat Panel - flexible width */}
-
+
{messages.length === 0 ? (
@@ -248,41 +389,50 @@ export function ChatPage() {

Send a message to begin interacting with CraftBot

) : ( - messages.map((msg, idx) => ( -
-
-
- {msg.sender} - - {new Date(msg.timestamp * 1000).toLocaleTimeString()} - -
-
- -
+
+ {loadingOlderMessages && ( +
+ Loading older messages...
- {msg.attachments && msg.attachments.length > 0 && ( -
- { + const message = messages[virtualItem.index] + return ( +
+
- )} -
- )) + ) + })} +
)} -
{/* Status bar */}
- - {connected ? status.message : 'Disconnected'} + + {status.message}
{/* Input area */} @@ -319,6 +469,23 @@ export function ChatPage() {
)} + {/* Reply bar - shows when replying to a message/task */} + {replyTarget && ( +
+ + + Replying to: {replyTarget.displayName} + + +
+ )} + {/* Pending attachments preview */} {pendingAttachments.length > 0 && (
@@ -389,25 +556,38 @@ export function ChatPage() { > {task.name} - {task.status === 'running' && ( - { - e.stopPropagation() - cancelTask(task.id) - }} - disabled={cancellingTaskId === task.id} - title="Cancel Task" - icon={ - cancellingTaskId === task.id ? ( - - ) : ( - - ) - } - /> + {(task.status === 'running' || task.status === 'waiting') && ( + <> + { + e.stopPropagation() + handleTaskReply(task.id, task.name) + }} + title="Reply to Task" + icon={} + /> + { + e.stopPropagation() + cancelTask(task.id) + }} + disabled={cancellingTaskId === task.id} + title="Cancel Task" + icon={ + cancellingTaskId === task.id ? ( + + ) : ( + + ) + } + /> + )}
{selectedTaskId === task.id && ( diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.module.css b/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.module.css index df1fb61a..94cd561d 100644 --- a/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.module.css @@ -708,6 +708,21 @@ border-radius: var(--radius-sm); } +.viewAllButton { + width: 100%; + padding: var(--space-1) 0; + font-size: var(--text-xs); + color: var(--color-primary); + background: none; + border: none; + cursor: pointer; + text-align: center; +} + +.viewAllButton:hover { + text-decoration: underline; +} + /* Empty State */ .emptyState { display: flex; diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx b/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx index 40c28910..9f9c951a 100644 --- a/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -19,6 +19,7 @@ import { } from 'lucide-react' import { useWebSocket } from '../../contexts/WebSocketContext' import { Badge, StatusIndicator } from '../../components/ui' +import { useDerivedAgentStatus } from '../../hooks' import type { MetricsTimePeriod } from '../../types' import styles from './DashboardPage.module.css' @@ -98,13 +99,24 @@ function getChartLabels(period: MetricsTimePeriod): { title: string; description } export function DashboardPage() { - const { status, actions, dashboardMetrics, filteredMetricsCache, requestFilteredMetrics } = useWebSocket() + const { connected, actions, messages, dashboardMetrics, filteredMetricsCache, requestFilteredMetrics } = useWebSocket() + + // Derive agent status from actions and messages + const status = useDerivedAgentStatus({ + actions, + messages, + connected, + }) // Time period state for each card const [taskPeriod, setTaskPeriod] = useState('total') const [tokenPeriod, setTokenPeriod] = useState('total') const [usagePeriod, setUsagePeriod] = useState('total') + // Expand/collapse state for top tools/skills lists + const [showAllTools, setShowAllTools] = useState(false) + const [showAllSkills, setShowAllSkills] = useState(false) + // Request filtered metrics when period changes (for all periods including 'total') const handlePeriodChange = useCallback(( period: MetricsTimePeriod, @@ -468,19 +480,16 @@ export function DashboardPage() {

MCP Servers

- 0 ? 'success' : 'default'}> - {mcpConnectedServers}/{mcpTotalServers} -
- - {mcpTotalTools} - Tools + + {mcpConnectedServers} + Connected
- + {mcpTotalCalls} Calls
@@ -489,13 +498,21 @@ export function DashboardPage() {
Top Tools
{mcpTopTools.length > 0 ? (
- {mcpTopTools.slice(0, 3).map((tool, index) => ( + {(showAllTools ? mcpTopTools : mcpTopTools.slice(0, 3)).map((tool, index) => (
#{index + 1} {tool.name} {tool.count}
))} + {mcpTopTools.length > 3 && ( + + )}
) : (
No usage yet
@@ -509,9 +526,6 @@ export function DashboardPage() {

Skills

- 0 ? 'success' : 'default'}> - {skillEnabled}/{skillTotal} -
@@ -530,13 +544,21 @@ export function DashboardPage() {
Top Skills
{topSkills.length > 0 ? (
- {topSkills.slice(0, 3).map((skill, index) => ( + {(showAllSkills ? topSkills : topSkills.slice(0, 3)).map((skill, index) => (
#{index + 1} {skill.name} {skill.count}
))} + {topSkills.length > 3 && ( + + )}
) : (
No usage yet
diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css index 5a4c81b8..41ead2ac 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css @@ -541,6 +541,256 @@ } } +/* ───────────────────────────────────────────────────────────────────── + Ollama / Local LLM Setup + ───────────────────────────────────────────────────────────────────── */ + +.ollamaBox { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); +} + +.ollamaStatusRow { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + font-weight: var(--font-medium); +} + +.ollamaStatusLabel { + color: var(--text-primary); +} + +.ollamaHint { + font-size: var(--text-sm); + color: var(--text-secondary); + margin: 0; + line-height: 1.5; +} + +.ollamaSuccessMsg { + font-size: var(--text-sm); + color: var(--color-success); + margin: 0; +} + +.ollamaLabel { + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.ollamaInputRow { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.ollamaInput { + flex: 1; + padding: var(--space-2) var(--space-3); + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: var(--text-sm); + font-family: var(--font-mono, monospace); + transition: border-color var(--transition-base); +} + +.ollamaInput:focus { + outline: none; + border-color: var(--color-primary); +} + +.ollamaError { + font-size: var(--text-xs); + color: var(--color-error); + margin: 0; +} + +.ollamaChecking { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.spinnerSmall { + width: 16px; + height: 16px; + border: 2px solid var(--border-primary); + border-top-color: var(--color-primary); + border-radius: var(--radius-full); + animation: spin 1s linear infinite; + flex-shrink: 0; +} + +.installLog { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 140px; + overflow-y: auto; + padding: var(--space-2) var(--space-3); + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); +} + +.installLogLine { + font-size: var(--text-xs); + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; +} + +.iconSuccess { + color: var(--color-success); + flex-shrink: 0; +} + +.iconError { + color: var(--color-error); + flex-shrink: 0; +} + +.iconWarning { + color: var(--color-warning); + flex-shrink: 0; +} + +.iconMuted { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.downloadProgressBar { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--bg-tertiary, rgba(255,255,255,0.1)); + overflow: hidden; + margin: 10px 0 6px; +} + +.downloadProgressFill { + height: 100%; + border-radius: 3px; + background: var(--color-primary); + transition: width 0.3s ease; +} + +.downloadProgressInfo { + display: flex; + justify-content: space-between; + font-size: var(--text-xs); + color: var(--text-secondary); + margin-bottom: 8px; +} + +.downloadStatus { + font-size: var(--text-xs); + color: var(--text-tertiary); + font-family: var(--font-mono, monospace); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.modelSearchInput { + width: 100%; + box-sizing: border-box; + padding: 8px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: var(--text-sm); + margin-bottom: 8px; + outline: none; +} + +.modelSearchInput::placeholder { + color: var(--text-tertiary); +} + +.modelSearchInput:focus { + border-color: var(--color-primary); +} + +.modelList { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 220px; + overflow-y: auto; + margin-bottom: 14px; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + background: var(--bg-secondary); +} + +.modelOption { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: var(--text-sm); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 0; + transition: background 0.1s; +} + +.modelOption:first-child { + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.modelOption:last-child { + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +.modelOption:hover { + background: var(--bg-hover, rgba(255,255,255,0.06)); +} + +.modelOptionSelected { + background: var(--bg-hover, rgba(255,255,255,0.08)); +} + +.modelOption input[type="radio"] { + accent-color: var(--color-primary); + flex-shrink: 0; +} + +.modelOptionName { + flex: 1; +} + +.modelOptionSize { + font-size: var(--text-xs); + color: var(--text-tertiary); + white-space: nowrap; +} + +.modelOptionBadge { + font-size: var(--text-xs); + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 15%, transparent); + border-radius: var(--radius-sm); + padding: 1px 6px; + white-space: nowrap; +} + /* Touch device optimizations */ @media (hover: none) and (pointer: coarse) { .optionItem { diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx index 0ac87419..5cb90a27 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx @@ -21,6 +21,11 @@ import { ClipboardList, Cloud, Sheet, + Download, + Play, + Wifi, + WifiOff, + RefreshCw, type LucideIcon, } from 'lucide-react' import { Button } from '../../components/ui' @@ -49,6 +54,267 @@ const ICON_MAP: Record = { const STEP_NAMES = ['Provider', 'API Key', 'Agent Name', 'MCP Servers', 'Skills'] +// ── Ollama local-setup component ───────────────────────────────────────────── + +interface OllamaSetupProps { + defaultUrl: string + onConnected: (url: string) => void +} + +function OllamaSetup({ defaultUrl, onConnected }: OllamaSetupProps) { + const { localLLM, checkLocalLLM, testLocalLLMConnection, installLocalLLM, startLocalLLM, pullOllamaModel } = useWebSocket() + const [url, setUrl] = useState(defaultUrl) + const [selectedModel, setSelectedModel] = useState('llama3.2:3b') + const [modelSearch, setModelSearch] = useState('') + + // Auto-check on mount + useEffect(() => { + checkLocalLLM() + }, [checkLocalLLM]) + + // Pre-select the recommended model when the list loads + useEffect(() => { + if (localLLM.suggestedModels.length > 0) { + const rec = localLLM.suggestedModels.find(m => m.recommended) + if (rec) setSelectedModel(rec.name) + } + }, [localLLM.suggestedModels]) + + // Notify parent when connected + useEffect(() => { + if (localLLM.phase === 'connected' && localLLM.testResult?.success) { + onConnected(url) + } + }, [localLLM.phase, localLLM.testResult, url, onConnected]) + + const { phase, installProgress, testResult, error } = localLLM + + const isWorking = phase === 'checking' || phase === 'installing' || phase === 'starting' || phase === 'pulling_model' + + // ── Checking ── + if (phase === 'idle' || phase === 'checking') { + return ( +
+
+
+ Checking if Ollama is running… +
+
+ ) + } + + // ── Not installed ── + if (phase === 'not_installed') { + return ( +
+
+ + Ollama is not installed +
+

+ Ollama lets you run AI models locally — no cloud needed. We'll install it automatically for you. +

+ +
+ ) + } + + // ── Installing ── + if (phase === 'installing') { + return ( +
+
+
+ Installing Ollama… +
+
+ {installProgress.length === 0 && Starting…} + {installProgress.map((line, i) => ( + {line} + ))} +
+
+ ) + } + + // ── Installed but not running ── + if (phase === 'not_running') { + return ( +
+
+ + Ollama is installed but not running +
+

Click below to start the Ollama server.

+ +
+ ) + } + + // ── Starting ── + if (phase === 'starting') { + return ( +
+
+
+ Starting Ollama… +
+
+ ) + } + + // ── Error ── + if (phase === 'error') { + return ( +
+
+ + Something went wrong +
+ {error &&

{error}

} + +
+ ) + } + + // ── Select model ── + if (phase === 'selecting_model') { + const allModels = localLLM.suggestedModels.length > 0 ? localLLM.suggestedModels : [] + const filteredModels = allModels.filter(m => + m.name.toLowerCase().includes(modelSearch.toLowerCase()) || + m.label.toLowerCase().includes(modelSearch.toLowerCase()) + ) + return ( +
+
+ + Ollama is running — no models yet +
+

Select a model to download so you can start chatting:

+ setModelSearch(e.target.value)} + /> +
+ {filteredModels.map(m => ( + + ))} + {filteredModels.length === 0 && ( +

No models match "{modelSearch}"

+ )} +
+ +
+ ) + } + + // ── Pulling model ── + if (phase === 'pulling_model') { + const bytes = localLLM.pullBytes + const fmtBytes = (n: number) => { + if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB` + if (n >= 1048576) return `${(n / 1048576).toFixed(0)} MB` + return `${(n / 1024).toFixed(0)} KB` + } + const latestStatus = localLLM.pullProgress[localLLM.pullProgress.length - 1] ?? 'Starting download…' + return ( +
+
+
+ Downloading {selectedModel}… +
+ {bytes && bytes.total > 0 ? ( + <> +
+
+
+
+ {fmtBytes(bytes.completed)} / {fmtBytes(bytes.total)} + {bytes.percent}% +
+ + ) : ( +
+
+
+ )} +

{latestStatus}

+
+ ) + } + + // ── Running — show URL field + test button ── + const connected = phase === 'connected' && testResult?.success + + return ( +
+
+ {connected + ? + : } + + {connected ? 'Connected to Ollama' : 'Ollama is running'} + +
+ + {connected && testResult?.message && ( +

{testResult.message}

+ )} + + {!connected && ( + <> + +
+ setUrl(e.target.value)} + placeholder="http://localhost:11434" + disabled={isWorking} + /> + +
+ {testResult && !testResult.success && ( +

{testResult.error}

+ )} + + )} +
+ ) +} + +// ── Main onboarding page ────────────────────────────────────────────────────── + export function OnboardingPage() { const { connected, @@ -59,11 +325,15 @@ export function OnboardingPage() { submitOnboardingStep, skipOnboardingStep, goBackOnboardingStep, + localLLM, } = useWebSocket() // Local form state const [selectedValue, setSelectedValue] = useState('') const [textValue, setTextValue] = useState('') + // URL submitted from OllamaSetup + const [ollamaUrl, setOllamaUrl] = useState('http://localhost:11434') + const [ollamaConnected, setOllamaConnected] = useState(false) // Request first step when connected useEffect(() => { @@ -75,86 +345,81 @@ export function OnboardingPage() { // Reset local state when step changes useEffect(() => { if (onboardingStep) { - // For multi-select steps, use array + setOllamaConnected(false) + if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') { setSelectedValue(Array.isArray(onboardingStep.default) ? onboardingStep.default : []) } else if (onboardingStep.options.length > 0) { - // Single select - find default option const defaultOption = onboardingStep.options.find(opt => opt.default) setSelectedValue(defaultOption?.value || onboardingStep.options[0]?.value || '') } else { - // Text input setSelectedValue('') setTextValue(typeof onboardingStep.default === 'string' ? onboardingStep.default : '') } } }, [onboardingStep]) + // Keep ollamaUrl in sync with step default + useEffect(() => { + if (onboardingStep?.name === 'api_key' && onboardingStep.provider === 'remote') { + const def = typeof onboardingStep.default === 'string' ? onboardingStep.default : 'http://localhost:11434' + setOllamaUrl(def) + } + }, [onboardingStep]) + + const handleOllamaConnected = useCallback((url: string) => { + setOllamaUrl(url) + setOllamaConnected(true) + }, []) + const handleOptionSelect = useCallback((value: string) => { if (!onboardingStep) return - if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') { - // Multi-select toggle setSelectedValue(prev => { const arr = Array.isArray(prev) ? prev : [] - if (arr.includes(value)) { - return arr.filter(v => v !== value) - } else { - return [...arr, value] - } + return arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value] }) } else { - // Single select setSelectedValue(value) } }, [onboardingStep]) const handleSubmit = useCallback(() => { if (!onboardingStep) return + const isOllamaStep = onboardingStep.name === 'api_key' && onboardingStep.provider === 'remote' - if (onboardingStep.options.length > 0) { - // Option-based step + if (isOllamaStep) { + submitOnboardingStep(ollamaUrl) + } else if (onboardingStep.options.length > 0) { submitOnboardingStep(selectedValue) } else { - // Text input step submitOnboardingStep(textValue) } - }, [onboardingStep, selectedValue, textValue, submitOnboardingStep]) - - const handleSkip = useCallback(() => { - skipOnboardingStep() - }, [skipOnboardingStep]) + }, [onboardingStep, selectedValue, textValue, ollamaUrl, submitOnboardingStep]) - const handleBack = useCallback(() => { - goBackOnboardingStep() - }, [goBackOnboardingStep]) + const handleSkip = useCallback(() => skipOnboardingStep(), [skipOnboardingStep]) + const handleBack = useCallback(() => goBackOnboardingStep(), [goBackOnboardingStep]) const isMultiSelect = onboardingStep?.name === 'mcp' || onboardingStep?.name === 'skills' - const isWideStep = isMultiSelect // MCP and Skills need wider container + const isWideStep = isMultiSelect const isLastStep = onboardingStep ? onboardingStep.index === onboardingStep.total - 1 : false - // Check if submit is valid + const isOllamaStep = + onboardingStep?.name === 'api_key' && onboardingStep?.provider === 'remote' + const canSubmit = (() => { if (!onboardingStep) return false if (onboardingLoading) return false - + if (isOllamaStep) { + return ollamaConnected || (localLLM.phase === 'connected' && !!localLLM.testResult?.success) + } if (onboardingStep.options.length > 0) { - if (isMultiSelect) { - // Multi-select: can always submit (empty array is valid) - return true - } - // Single select: must have selection - return !!selectedValue - } else { - // Text input: required steps need non-empty value - if (onboardingStep.required) { - return textValue.trim().length > 0 - } - return true + return isMultiSelect ? true : !!selectedValue } + return onboardingStep.required ? textValue.trim().length > 0 : true })() - // Render loading state + // Loading if (!connected || (!onboardingStep && onboardingLoading)) { return (
@@ -170,11 +435,23 @@ export function OnboardingPage() { ) } - // Render step content + // ── Render step form ────────────────────────────────────────────────────── const renderStepForm = () => { if (!onboardingStep) return null - // Option-based step (single or multi select) + // Ollama local setup + if (isOllamaStep) { + return ( +
+ +
+ ) + } + + // Option-based step if (onboardingStep.options.length > 0) { return (
@@ -219,26 +496,19 @@ export function OnboardingPage() { // Text input step const isApiKey = onboardingStep.name === 'api_key' - return (
setTextValue(e.target.value)} + onChange={e => setTextValue(e.target.value)} placeholder={isApiKey ? 'Enter your API key' : 'Enter a name'} autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && canSubmit) { - handleSubmit() - } - }} + onKeyDown={e => { if (e.key === 'Enter' && canSubmit) handleSubmit() }} /> {isApiKey && ( -
- Your API key is stored locally. -
+
Your API key is stored locally.
)}
) @@ -256,14 +526,10 @@ export function OnboardingPage() { return (
-
+
{isCompleted ? : index + 1}
- - {name} - + {name}
{index < STEP_NAMES.length - 1 && (
@@ -284,7 +550,25 @@ export function OnboardingPage() { Optional )} -

{onboardingStep.description}

+

+ {isOllamaStep ? (() => { + switch (localLLM.phase) { + case 'not_installed': return "Ollama isn't installed yet — we'll download and install it automatically." + case 'installing': return "Installing Ollama on your machine. This may take a minute…" + case 'not_running': return "Ollama is installed but not running. Click below to start the server." + case 'starting': return "Starting the Ollama server…" + case 'running': return "Ollama is running. Enter the server URL and test the connection." + case 'selecting_model': return "Ollama is connected but has no models yet. Pick one to download." + case 'pulling_model': return "Downloading your model — this may take a few minutes depending on size." + case 'connected': { + const n = localLLM.testResult?.models?.length ?? 0 + return `Connected to Ollama — ${n} model${n === 1 ? '' : 's'} available.` + } + case 'error': return localLLM.error ?? "Something went wrong. Check the error below and retry." + default: return "Checking Ollama status…" + } + })() : onboardingStep.description} +

{/* Error Message */} {onboardingError && ( @@ -301,24 +585,14 @@ export function OnboardingPage() {
{onboardingStep.index > 0 && ( - )}
{!onboardingStep.required && ( - )} @@ -331,10 +605,8 @@ export function OnboardingPage() { iconPosition="right" > {onboardingLoading && onboardingStep?.name === 'api_key' - ? 'Testing API Key...' - : isLastStep - ? 'Finish' - : 'Next'} + ? (isOllamaStep ? 'Connecting…' : 'Testing API Key…') + : isLastStep ? 'Finish' : 'Next'}
diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx new file mode 100644 index 00000000..ca399961 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx @@ -0,0 +1,516 @@ +import { useState, useEffect, useRef } from 'react' +import { + ChevronRight, + RotateCcw, + FileText, + AlertTriangle, + Check, + X, + Loader2, +} from 'lucide-react' +import { Button, Badge, ConfirmModal } from '../../components/ui' +import { useTheme } from '../../contexts/ThemeContext' +import { useConfirmModal } from '../../hooks' +import styles from './SettingsPage.module.css' +import { useSettingsWebSocket } from './useSettingsWebSocket' + +// Theme application helper +function applyTheme(theme: string) { + const root = document.documentElement + + if (theme === 'system') { + // Check system preference + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + root.setAttribute('data-theme', prefersDark ? 'dark' : 'light') + } else { + root.setAttribute('data-theme', theme) + } + + // Persist to localStorage + localStorage.setItem('craftbot-theme', theme) +} + +// Get initial theme from localStorage or default +function getInitialTheme(): string { + return localStorage.getItem('craftbot-theme') || 'dark' +} + +// Get initial agent name from localStorage or default +function getInitialAgentName(): string { + return localStorage.getItem('craftbot-agent-name') || 'CraftBot' +} + +export function GeneralSettings() { + const { send, onMessage, isConnected } = useSettingsWebSocket() + const { theme: globalTheme, setTheme: setGlobalTheme } = useTheme() + const [agentName, setAgentName] = useState(getInitialAgentName) + const [initialAgentName, setInitialAgentName] = useState(getInitialAgentName) + const [theme, setTheme] = useState(getInitialTheme) + const [initialTheme, setInitialTheme] = useState(getInitialTheme) + const [isResetting, setIsResetting] = useState(false) + const [resetStatus, setResetStatus] = useState<'idle' | 'success' | 'error'>('idle') + const [isSaving, setIsSaving] = useState(false) + const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle') + + // Agent file states + const [userMdContent, setUserMdContent] = useState('') + const [originalUserMdContent, setOriginalUserMdContent] = useState('') + const [agentMdContent, setAgentMdContent] = useState('') + const [originalAgentMdContent, setOriginalAgentMdContent] = useState('') + // Refs to track current content for closure-safe callbacks + const userMdContentRef = useRef(userMdContent) + const agentMdContentRef = useRef(agentMdContent) + userMdContentRef.current = userMdContent + agentMdContentRef.current = agentMdContent + const [isLoadingUserMd, setIsLoadingUserMd] = useState(false) + const [isLoadingAgentMd, setIsLoadingAgentMd] = useState(false) + const [isSavingUserMd, setIsSavingUserMd] = useState(false) + const [isSavingAgentMd, setIsSavingAgentMd] = useState(false) + const [isRestoringUserMd, setIsRestoringUserMd] = useState(false) + const [isRestoringAgentMd, setIsRestoringAgentMd] = useState(false) + const [userMdSaveStatus, setUserMdSaveStatus] = useState<'idle' | 'success' | 'error'>('idle') + const [agentMdSaveStatus, setAgentMdSaveStatus] = useState<'idle' | 'success' | 'error'>('idle') + const [showAdvanced, setShowAdvanced] = useState(false) + + // Confirm modal + const { modalProps: confirmModalProps, confirm } = useConfirmModal() + + // Computed dirty states + const isUserMdDirty = userMdContent !== originalUserMdContent + const isAgentMdDirty = agentMdContent !== originalAgentMdContent + const isGeneralSettingsDirty = agentName !== initialAgentName || theme !== initialTheme + + // Sync local theme when global theme changes (e.g., from TopBar button) + useEffect(() => { + // Only sync if current theme is not 'system' (system theme should stay as 'system') + if (initialTheme !== 'system' && globalTheme !== initialTheme) { + setTheme(globalTheme) + setInitialTheme(globalTheme) + applyTheme(globalTheme) + } + }, [globalTheme, initialTheme]) + + // Apply theme on mount and when saved (initialTheme changes after save) + useEffect(() => { + applyTheme(initialTheme) + }, [initialTheme]) + + // Listen for system theme changes when using 'system' theme + useEffect(() => { + if (initialTheme !== 'system') return + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = () => applyTheme('system') + + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, [initialTheme]) + + // Load initial settings and files + useEffect(() => { + if (!isConnected) return + + // Set up message handlers + const cleanups = [ + onMessage('settings_get', (data: unknown) => { + const d = data as { success: boolean; settings?: { agentName: string; theme: string } } + if (d.success && d.settings) { + setAgentName(d.settings.agentName) + setTheme(d.settings.theme) + } + }), + onMessage('settings_update', (data: unknown) => { + const d = data as { success: boolean } + setIsSaving(false) + if (d.success) { + // Settings saved + } + }), + onMessage('reset', (data: unknown) => { + const d = data as { success: boolean } + setIsResetting(false) + setResetStatus(d.success ? 'success' : 'error') + setTimeout(() => setResetStatus('idle'), 3000) + }), + onMessage('agent_file_read', (data: unknown) => { + const d = data as { filename: string; content: string; success: boolean } + if (d.filename === 'USER.md') { + setIsLoadingUserMd(false) + if (d.success) { + setUserMdContent(d.content) + setOriginalUserMdContent(d.content) + } + } else if (d.filename === 'AGENT.md') { + setIsLoadingAgentMd(false) + if (d.success) { + setAgentMdContent(d.content) + setOriginalAgentMdContent(d.content) + } + } + }), + onMessage('agent_file_write', (data: unknown) => { + const d = data as { filename: string; success: boolean } + if (d.filename === 'USER.md') { + setIsSavingUserMd(false) + if (d.success) { + setOriginalUserMdContent(userMdContentRef.current) + } + setUserMdSaveStatus(d.success ? 'success' : 'error') + setTimeout(() => setUserMdSaveStatus('idle'), 3000) + } else if (d.filename === 'AGENT.md') { + setIsSavingAgentMd(false) + if (d.success) { + setOriginalAgentMdContent(agentMdContentRef.current) + } + setAgentMdSaveStatus(d.success ? 'success' : 'error') + setTimeout(() => setAgentMdSaveStatus('idle'), 3000) + } + }), + onMessage('agent_file_restore', (data: unknown) => { + const d = data as { filename: string; content: string; success: boolean } + if (d.filename === 'USER.md') { + setIsRestoringUserMd(false) + if (d.success) { + setUserMdContent(d.content) + setOriginalUserMdContent(d.content) + setUserMdSaveStatus('success') + setTimeout(() => setUserMdSaveStatus('idle'), 3000) + } + } else if (d.filename === 'AGENT.md') { + setIsRestoringAgentMd(false) + if (d.success) { + setAgentMdContent(d.content) + setOriginalAgentMdContent(d.content) + setAgentMdSaveStatus('success') + setTimeout(() => setAgentMdSaveStatus('idle'), 3000) + } + } + }), + ] + + // Request initial data + send('settings_get') + + return () => { + cleanups.forEach(cleanup => cleanup()) + } + }, [isConnected, send, onMessage]) + + // Load advanced files when section is opened + useEffect(() => { + if (showAdvanced && isConnected) { + setIsLoadingUserMd(true) + setIsLoadingAgentMd(true) + send('agent_file_read', { filename: 'USER.md' }) + send('agent_file_read', { filename: 'AGENT.md' }) + } + }, [showAdvanced, isConnected, send]) + + const handleSaveSettings = () => { + setIsSaving(true) + + // Persist agent name to localStorage + localStorage.setItem('craftbot-agent-name', agentName) + + // Sync the global theme context (for TopBar) + // Resolve 'system' to actual theme for the context + if (theme === 'system') { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + setGlobalTheme(prefersDark ? 'dark' : 'light') + } else { + setGlobalTheme(theme as 'dark' | 'light') + } + + // Update the initial values to mark as not dirty + // This triggers the useEffect that applies the theme + setInitialAgentName(agentName) + setInitialTheme(theme) + + // Send to backend (for potential server-side persistence) + send('settings_update', { settings: { agentName, theme } }) + + // Show success feedback + setIsSaving(false) + setSaveStatus('success') + setTimeout(() => setSaveStatus('idle'), 3000) + } + + const handleReset = () => { + confirm({ + title: 'Reset Agent', + message: 'Are you sure you want to reset the agent? This will clear all current tasks, conversation history, and restore the agent file system to its default state.', + confirmText: 'Reset', + variant: 'danger', + }, () => { + setIsResetting(true) + send('reset') + }) + } + + const handleSaveUserMd = () => { + setIsSavingUserMd(true) + send('agent_file_write', { filename: 'USER.md', content: userMdContent }) + } + + const handleSaveAgentMd = () => { + setIsSavingAgentMd(true) + send('agent_file_write', { filename: 'AGENT.md', content: agentMdContent }) + } + + const handleRestoreUserMd = () => { + confirm({ + title: 'Restore USER.md', + message: 'Are you sure you want to restore USER.md to its default template? This will overwrite your current customizations.', + confirmText: 'Restore', + variant: 'danger', + }, () => { + setIsRestoringUserMd(true) + send('agent_file_restore', { filename: 'USER.md' }) + }) + } + + const handleRestoreAgentMd = () => { + confirm({ + title: 'Restore AGENT.md', + message: 'Are you sure you want to restore AGENT.md to its default template? This will overwrite your current customizations.', + confirmText: 'Restore', + variant: 'danger', + }, () => { + setIsRestoringAgentMd(true) + send('agent_file_restore', { filename: 'AGENT.md' }) + }) + } + + return ( +
+
+

General Settings

+

Configure basic agent settings and preferences

+
+ +
+
+ + setAgentName(e.target.value)} + placeholder="Enter agent name" + /> + The name displayed in conversations +
+ +
+ + +
+
+ +
+ + {saveStatus === 'success' && ( + + Settings saved + + )} + {saveStatus === 'error' && ( + + Save failed + + )} +
+ + {/* Reset Section */} +
+
+ +

Reset Agent

+
+

+ Reset the agent to its initial state. This will clear the current task, conversation history, + and restore the agent file system from templates. Saved settings and credentials are preserved. +

+ + {resetStatus === 'success' && ( + + Agent reset successfully + + )} + {resetStatus === 'error' && ( + + Reset failed + + )} +
+ + {/* Advanced Section */} +
+ + + {showAdvanced && ( +
+ {/* USER.md Editor */} +
+
+
+

USER.md

+ User Profile +
+

+ This file contains your personal information and preferences that help the agent + understand how to interact with you. Editing this file will change how the agent + addresses you and tailors its responses to your preferences. +

+
+
+ {isLoadingUserMd ? ( +
+ + Loading USER.md... +
+ ) : ( +