From 7b443b40a4f7f3461ade0c4b7cc16aa3e351e6ee Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Sat, 9 May 2026 13:38:38 -0400 Subject: [PATCH] feat: http_post tool, nc_talk_history tool, local orchestrator retry - http_post: POST to external URLs with per-user URL prefix allowlist (home/{user}/http_allowlist.json); admin-only, confirm-required - nc_talk_history: read recent NC Talk messages via Basic Auth (requires nc_username + nc_app_password in channels.json under nextcloud) - openai_orchestrator: _chat_with_retry() wraps both API calls with exponential backoff (3 attempts, 1s/2s) on connection errors and transient status codes (429, 500, 502, 503, 504) - Docs updated: CLAUDE.md, HELP.md, TODO, MASTER, ROADMAP (50 tools) Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 6 +-- cortex/openai_orchestrator.py | 30 +++++++++-- cortex/static/HELP.md | 10 ++-- cortex/tools/__init__.py | 13 +++-- cortex/tools/notify.py | 86 +++++++++++++++++++++++++++++++ cortex/tools/web.py | 97 ++++++++++++++++++++++++++++++++++- documentation/MASTER.md | 2 + documentation/ROADMAP.md | 3 ++ documentation/TODO__Agents.md | 23 +++++---- 9 files changed, 244 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a42f4d3..14536c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,7 +250,7 @@ clearly asked for a directory to be unblocked. --- -## Current State (2026-05-08) +## Current State (2026-05-09) Cortex is running and stable. All channels are live: @@ -269,12 +269,12 @@ Cortex is running and stable. All channels are live: Active users: scott (inara), holly (tina), brian (wintermute) -**47 orchestrator tools:** web_search, http_fetch, web_read, +**50 orchestrator tools:** web_search, http_fetch, web_read, http_post, file_read/list/write/session_read/session_search, shell_exec, claude_allow_dir, cortex_restart/logs/status/update, task_list/create/update/complete, cron_list/add/remove/toggle, reminders_add/list/remove/clear, scratch_read/write/append/clear, -web_push, email_send, nc_talk_send, +web_push, email_send, nc_talk_send, nc_talk_history, ae_journal_list/search/entries_list/entry_read/entry_create/entry_update/entry_disable/entry_append/entry_prepend, ae_task_list, agent_notes_read/write/append/clear, spawn_agent. diff --git a/cortex/openai_orchestrator.py b/cortex/openai_orchestrator.py index 00af501..cc61aff 100644 --- a/cortex/openai_orchestrator.py +++ b/cortex/openai_orchestrator.py @@ -21,7 +21,7 @@ import asyncio import json import logging -from openai import AsyncOpenAI +from openai import AsyncOpenAI, APIConnectionError, APIStatusError from config import settings from orchestrator_engine import OrchestrateCheckpoint, OrchestratorResult @@ -286,7 +286,7 @@ async def _run_from_messages( if active_tools: call_kwargs["tools"] = active_tools call_kwargs["tool_choice"] = "auto" - response = await client.chat.completions.create(**call_kwargs) + response = await _chat_with_retry(client, **call_kwargs) choice = response.choices[0] msg = choice.message @@ -345,7 +345,7 @@ async def _run_from_messages( conf_call: dict = {"model": model_name, "messages": messages, "tool_choice": "none"} if active_tools: conf_call["tools"] = active_tools - conf_resp = await client.chat.completions.create(**conf_call) + conf_resp = await _chat_with_retry(client, **conf_call) final_response = conf_resp.choices[0].message.content or ( "This action requires your explicit confirmation before it can proceed." ) @@ -386,6 +386,30 @@ async def _run_from_messages( return final_response, None +_RETRY_STATUSES = {429, 500, 502, 503, 504} +_MAX_API_RETRIES = 3 + + +async def _chat_with_retry(client, **kwargs): + """Wrap chat.completions.create with exponential backoff on transient errors.""" + last_exc: Exception = RuntimeError("No attempts made") + for attempt in range(_MAX_API_RETRIES): + try: + return await client.chat.completions.create(**kwargs) + except APIConnectionError as e: + last_exc = e + logger.warning("OpenAI connection error (attempt %d/%d): %s", attempt + 1, _MAX_API_RETRIES, e) + except APIStatusError as e: + if e.status_code in _RETRY_STATUSES: + last_exc = e + logger.warning("OpenAI status %d (attempt %d/%d): %s", e.status_code, attempt + 1, _MAX_API_RETRIES, e) + else: + raise + if attempt < _MAX_API_RETRIES - 1: + await asyncio.sleep(2 ** attempt) # 1s, 2s + raise last_exc + + def _build_client( model_cfg: dict | None, user_role: str = "user", diff --git a/cortex/static/HELP.md b/cortex/static/HELP.md index b93bf69..2e667c5 100644 --- a/cortex/static/HELP.md +++ b/cortex/static/HELP.md @@ -82,11 +82,11 @@ Orchestrated sessions persist to history exactly like regular chat. ### Available Tools -47 tools across 12 categories. Each tool schema is sent to the model on every orchestrated call — fewer active tools means fewer tokens per call. +50 tools across 12 categories. Each tool schema is sent to the model on every orchestrated call — fewer active tools means fewer tokens per call. | Category | Tools | |---|---| -| **Web** | `web_search`, `http_fetch`, `web_read` | +| **Web** | `web_search`, `http_fetch`, `web_read`, `http_post` | | **Files** | `file_read`, `file_list`, `file_write`, `session_read`, `session_search` | | **Shell** | `shell_exec`, `claude_allow_dir` | | **System** | `cortex_restart`, `cortex_logs`, `cortex_status`, `cortex_update` | @@ -94,12 +94,14 @@ Orchestrated sessions persist to history exactly like regular chat. | **Cron** | `cron_list`, `cron_add`, `cron_remove`, `cron_toggle` | | **Reminders** | `reminders_add`, `reminders_list`, `reminders_remove`, `reminders_clear` | | **Scratchpad** | `scratch_read`, `scratch_write`, `scratch_append`, `scratch_clear` | -| **Notifications** | `web_push`, `email_send`, `nc_talk_send` | +| **Notifications** | `web_push`, `email_send`, `nc_talk_send`, `nc_talk_history` | | **Aether Journals** | `ae_journal_list/search`, `ae_journal_entries_list`, `ae_journal_entry_read/create/update/disable/append/prepend` | | **Agent Notes** | `agent_notes_read`, `agent_notes_write`, `agent_notes_append`, `agent_notes_clear` | | **Agents** | `spawn_agent` | -File, Shell, System, Agents, and some Notification tools are **admin-only** and not visible to regular users. +File, Shell, System, Agents, and some Notification/Web tools are **admin-only** and not visible to regular users. +`http_post` requires a URL prefix allowlist in `home/{user}/http_allowlist.json`. +`nc_talk_history` requires `nc_username` and `nc_app_password` in `channels.json` under `nextcloud`. ### Per-Role Tool Sets diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index e4d1e0a..0c37556 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -17,7 +17,7 @@ from google.genai import types # ── Callable imports ────────────────────────────────────────────────────────── -from tools.web import search as _web_search, http_fetch as _http_fetch, web_read as _web_read +from tools.web import search as _web_search, http_fetch as _http_fetch, web_read as _web_read, http_post as _http_post from tools.ae_knowledge import ( journal_list as _ae_journal_list, journal_search as _ae_journal_search, @@ -63,7 +63,7 @@ from tools.scratch import ( scratch_append as _scratch_append, scratch_clear as _scratch_clear, ) -from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send, web_push as _web_push +from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send, web_push as _web_push, nc_talk_history as _nc_talk_history from tools.agent_notes import ( agent_notes_read as _agent_notes_read, agent_notes_write as _agent_notes_write, @@ -90,7 +90,7 @@ import tools.agents as _mod_agents # ── Tool categories — used by the Model Registry UI for grouped checkboxes ─── TOOL_CATEGORIES: dict[str, list[str]] = { - "Web": ["web_search", "http_fetch", "web_read"], + "Web": ["web_search", "http_fetch", "web_read", "http_post"], "Files": ["file_read", "file_list", "file_write", "session_read", "session_search"], "Shell": ["shell_exec", "claude_allow_dir"], "System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"], @@ -98,7 +98,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = { "Cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"], "Reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"], "Scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_clear"], - "Notifications": ["web_push", "email_send", "nc_talk_send"], + "Notifications": ["web_push", "email_send", "nc_talk_send", "nc_talk_history"], "Aether Journals": [ "ae_journal_list", "ae_journal_search", "ae_journal_entries_list", "ae_journal_entry_read", @@ -117,6 +117,7 @@ _CALLABLES: dict[str, callable] = { "web_search": _web_search, "http_fetch": _http_fetch, "web_read": _web_read, + "http_post": _http_post, "ae_journal_list": _ae_journal_list, "ae_journal_search": _ae_journal_search, "ae_journal_entry_read": _ae_journal_entry_read, @@ -157,6 +158,7 @@ _CALLABLES: dict[str, callable] = { "email_send": _email_send, "nc_talk_send": _nc_talk_send, "web_push": _web_push, + "nc_talk_history": _nc_talk_history, "agent_notes_read": _agent_notes_read, "agent_notes_write": _agent_notes_write, "agent_notes_append": _agent_notes_append, @@ -181,6 +183,8 @@ TOOL_ROLES: dict[str, str] = { "spawn_agent": "admin", "email_send": "admin", "nc_talk_send": "admin", + "http_post": "admin", + "nc_talk_history": "admin", } # Tools that require explicit user confirmation before executing. @@ -191,6 +195,7 @@ CONFIRM_REQUIRED: set[str] = { "shell_exec", "cron_remove", "reminders_clear", + "http_post", } _ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1} diff --git a/cortex/tools/notify.py b/cortex/tools/notify.py index a0bc470..0a7d6a9 100644 --- a/cortex/tools/notify.py +++ b/cortex/tools/notify.py @@ -10,6 +10,7 @@ import json import logging import re +import httpx from google.genai import types from config import settings from persona import get_user @@ -77,6 +78,74 @@ async def web_push(title: str, body: str, url: str = "") -> str: return f"Push sent to {result['sent']} device(s) for {username} (pruned {result['pruned']} stale)." +async def nc_talk_history(conversation_token: str = "", limit: int = 20) -> str: + """Read recent messages from a Nextcloud Talk conversation. + + Requires nc_username and nc_app_password in channels.json under 'nextcloud'. + conversation_token defaults to notification_room if not specified. + """ + from auth_utils import get_user_channels + username = get_user() + channels = get_user_channels(username) + nct = channels.get("nextcloud", {}) + + url = nct.get("url", "").rstrip("/") + nc_username = nct.get("nc_username", "").strip() + nc_app_password = nct.get("nc_app_password", "").strip() + token = conversation_token.strip() or nct.get("notification_room", "").strip() + + if not url or not nc_username or not nc_app_password: + return ( + "nc_talk_history requires nc_username and nc_app_password in channels.json " + f"(under 'nextcloud'). Add these to home/{username}/channels.json to enable message reading." + ) + if not token: + return "No conversation token provided and no notification_room set in channels.json." + + limit = min(max(int(limit), 1), 200) + return await asyncio.to_thread(_sync_nc_talk_history, url, nc_username, nc_app_password, token, limit) + + +def _sync_nc_talk_history(url: str, nc_user: str, nc_pass: str, token: str, limit: int) -> str: + from datetime import datetime, timezone + endpoint = f"{url}/ocs/v2.php/apps/spreed/api/v4/chat/{token}" + try: + resp = httpx.get( + endpoint, + params={"limit": limit, "lookIntoFuture": 0, "setReadMarker": 0, "noStatusUpdate": 1}, + auth=(nc_user, nc_pass), + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + timeout=15, + ) + except Exception as e: + return f"NC Talk API error: {e}" + + if resp.status_code != 200: + return f"NC Talk API returned HTTP {resp.status_code}: {resp.text[:200]}" + + try: + messages = resp.json().get("ocs", {}).get("data", []) + except Exception as e: + return f"Failed to parse NC Talk response: {e}" + + if not messages: + return "No messages found in this conversation." + + # NC Talk returns newest-first; reverse to chronological order + lines = [f"Last {len(messages)} messages from {token}:\n"] + for msg in reversed(messages): + sender = msg.get("actorDisplayName") or msg.get("actorId") or "Unknown" + ts = msg.get("timestamp", 0) + time_str = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + text = msg.get("message", "") + if msg.get("messageType") == "system": + lines.append(f"[system {time_str}] {text}") + else: + lines.append(f"{sender} ({time_str}): {text}") + + return "\n".join(lines) + + async def nc_talk_send(message: str) -> str: """Send a message to the user via their configured notification channel. @@ -145,4 +214,21 @@ DECLARATIONS = [ required=["message"], ), ), + types.FunctionDeclaration( + name="nc_talk_history", + description=( + "Read recent messages from a Nextcloud Talk conversation. Useful for checking " + "what was said in a room before composing a reply, or reviewing recent context. " + "Requires nc_username and nc_app_password in channels.json under 'nextcloud'. " + "conversation_token defaults to notification_room if not provided." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "conversation_token": types.Schema(type=types.Type.STRING, description="NC Talk room token (defaults to notification_room from channels.json)"), + "limit": types.Schema(type=types.Type.INTEGER, description="Number of messages to return (default 20, max 200)"), + }, + required=[], + ), + ), ] diff --git a/cortex/tools/web.py b/cortex/tools/web.py index 2ca5135..167056b 100644 --- a/cortex/tools/web.py +++ b/cortex/tools/web.py @@ -1,14 +1,17 @@ """ -Web tools — search (DuckDuckGo), direct HTTP fetch, and clean content extraction. +Web tools — search (DuckDuckGo), direct HTTP fetch, clean content extraction, and HTTP POST. """ import asyncio +import json import logging +from urllib.parse import urlparse import httpx from google.genai import types from config import settings +from persona import get_user logger = logging.getLogger(__name__) @@ -115,6 +118,80 @@ def _sync_web_read(url: str, max_chars: int) -> str: return f"Content from {url}:\n\n{text}" +def _load_http_allowlist(username: str) -> list[str]: + """Load per-user HTTP POST allowlist (URL prefixes). Empty list = all blocked.""" + path = settings.home_root() / username / "http_allowlist.json" + try: + return [str(p).strip() for p in json.loads(path.read_text()) if str(p).strip()] + except FileNotFoundError: + return [] + except Exception as e: + logger.warning("failed to read http_allowlist.json for %s: %s", username, e) + return [] + + +def _http_post_allowed(url: str, allowlist: list[str]) -> bool: + """Return True if url starts with any allowlist entry (prefix match).""" + for prefix in allowlist: + if url.startswith(prefix): + return True + return False + + +async def http_post( + url: str, + body: str = "", + headers: dict | None = None, + max_chars: int = 4096, +) -> str: + """POST to an external URL. Requires the URL to match home/{user}/http_allowlist.json. + + body may be a JSON string or plain text. If body is valid JSON, Content-Type is set + to application/json; otherwise text/plain. Override via the headers param. + Response is capped at max_chars (default 4096, max 131072). + """ + username = get_user() + allowlist = _load_http_allowlist(username) + if not allowlist: + return ( + f"http_post blocked — no allowlist configured. " + f"Add allowed URL prefixes to home/{username}/http_allowlist.json as a JSON array. " + f"Example: [\"https://api.example.com\"]" + ) + if not _http_post_allowed(url, allowlist): + return ( + f"http_post blocked — {url} does not match any allowlist entry for {username}. " + f"Add the URL prefix to home/{username}/http_allowlist.json." + ) + + max_chars = min(max(int(max_chars), 100), 131072) + + # Auto-detect content type from body + body_str = body if isinstance(body, str) else json.dumps(body) + try: + json.loads(body_str) + content_type = "application/json" + except (json.JSONDecodeError, ValueError): + content_type = "text/plain" + + req_headers = {"Content-Type": content_type} + if headers: + req_headers.update(headers) + + try: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + resp = await client.post(url, content=body_str.encode(), headers=req_headers) + body_text = resp.text[:max_chars] + truncated = len(resp.text) > max_chars + suffix = f"\n\n[… truncated at {max_chars} chars]" if truncated else "" + return f"HTTP {resp.status_code} {resp.url}\n\n{body_text}{suffix}" + except httpx.HTTPError as e: + return f"HTTP error: {e}" + except Exception as e: + logger.warning("http_post error for %s: %s", url, e) + return f"Error: {e}" + + DECLARATIONS = [ types.FunctionDeclaration( name="web_search", @@ -169,4 +246,22 @@ DECLARATIONS = [ required=["url"], ), ), + types.FunctionDeclaration( + name="http_post", + description=( + "POST to an external URL. Requires the URL to match the user's http_allowlist.json. " + "Use for calling webhooks, triggering automations, posting to APIs, or any HTTP action. " + "body is a string — JSON or plain text are both accepted (Content-Type auto-detected). " + "Override headers as needed. Response capped at max_chars (default 4096, max 131072)." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "url": types.Schema(type=types.Type.STRING, description="Full URL to POST to"), + "body": types.Schema(type=types.Type.STRING, description="Request body — JSON string or plain text"), + "max_chars": types.Schema(type=types.Type.INTEGER, description="Max response chars (default 4096, max 131072)"), + }, + required=["url"], + ), + ), ] diff --git a/documentation/MASTER.md b/documentation/MASTER.md index c09bed8..0281ea1 100644 --- a/documentation/MASTER.md +++ b/documentation/MASTER.md @@ -42,6 +42,8 @@ Cortex is a self-hosted personal AI platform. It routes messages from any input | Distill safety | ✅ Live | Per-persona asyncio lock, per-endpoint cooldowns, Rebuild option | | Guided onboarding | ✅ Live | Setup Step 3 for OpenRouter; existing-user banner; settings quick-link | +**50 orchestrator tools** — `http_post` (URL allowlist POST), `nc_talk_history` (read Talk messages), and local orchestrator retry logic added 2026-05-09. + **Active users / personas:** scott/inara, holly/tina, brian/wintermute --- diff --git a/documentation/ROADMAP.md b/documentation/ROADMAP.md index aed4672..0600435 100644 --- a/documentation/ROADMAP.md +++ b/documentation/ROADMAP.md @@ -45,6 +45,9 @@ - ✅ Sub-agent spawning — `spawn_agent` tool; per-host concurrency limit; Gemini API + local OpenAI backends - ✅ Web content extraction — `web_read` via trafilatura; strips ads/nav/boilerplate; 128K cap - ✅ Session log reader — `session_read(date)` tool; complements `session_search` +- ✅ `http_post` — POST to external URLs with per-user URL prefix allowlist; admin-only, confirm-required +- ✅ `nc_talk_history` — read recent NC Talk messages; requires nc_username + nc_app_password in channels.json +- ✅ Local orchestrator retry — exponential backoff on 429/5xx/connection errors (3 attempts) - [ ] Knowledge import — markdown → AE Journals (import script) - [ ] Dev agent pipeline — specialist agents + supervisor + approval gate - [ ] Gitea webhook integration + Actions CI diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index 0cc67a3..847c252 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -41,7 +41,9 @@ automatically. Remaining work is quality/reliability parity, not ground-up desig - [x] Context budget: `_context_budget()` uses `context_k * 1000 * 0.75`, min 16k — 2026-05-06 - [x] Context compaction: `_compact_messages()` trims old tool results before each round and before the confirmation-gate call — 2026-05-06 - [x] Error handling: malformed tool args caught + logged; tool execution errors returned as strings -- [ ] Retry logic on transient API errors (connection timeout, 429, 503) +- [x] Retry logic on transient API errors (connection timeout, 429, 503) — 2026-05-09 + - `_chat_with_retry()` helper in `openai_orchestrator.py`; 3 attempts, exponential backoff (1s, 2s) + - Retries on `APIConnectionError` and `APIStatusError` with status 429/500/502/503/504 - [ ] Test end-to-end with Gemma 4 E4B and 26B A4B on scott_gaming - [ ] Review `ARCH__FUTURE.md` agent architecture ideas before finalising design - Reference: `docs/OPEN_WEBUI_API.md`, `documentation/ARCH__FUTURE.md` §1 @@ -87,16 +89,15 @@ system prompt by `context_loader.py` at all tiers. - Supports `local_openai` and `gemini_api` model types; returns error string for others - Admin-only tool (powerful — can spawn arbitrarily long sub-tasks) - Host UI: "Max parallel" number input in host edit/add forms -- [ ] **`http_post`** — POST to external URLs - - Params: `url: str`, `body: dict | str`, `headers: dict | None` - - Per-user host allowlist in `home/{user}/http_allowlist.json` (same pattern as email) - - Default: blocked unless URL host matches an allowlist entry - - Confirm-required for safety -- [ ] **`nc_talk_history`** — read recent Talk messages before replying - - Params: `conversation_token: str`, `limit: int = 20` - - Returns last N messages with sender + timestamp - - Admin-only (requires NC Talk API credentials from channels.json) -- [ ] **`http_post`** — POST to external URLs with allowlist +- [x] **`http_post`** — POST to external URLs — 2026-05-09 + - Params: `url: str`, `body: str`, `headers: dict | None`, `max_chars: int` + - Per-user URL prefix allowlist in `home/{user}/http_allowlist.json` (JSON array of prefixes) + - Default: blocked if no allowlist or URL doesn't match any prefix + - Admin-only, confirm-required +- [x] **`nc_talk_history`** — read recent Talk messages — 2026-05-09 + - Params: `conversation_token: str` (optional, defaults to notification_room), `limit: int = 20` + - Returns last N messages with sender + timestamp, chronological order + - Admin-only; requires `nc_username` and `nc_app_password` in channels.json under `nextcloud` - [ ] **`task_list` priority filter** — add `priority` param alongside existing `status` - [x] **`http_fetch` max_chars** — optional param, default 8192, cap at 32768 — 2026-05-09 - [x] **`web_read(url, max_chars=16000)`** — clean article extraction via trafilatura; strips ads/nav/boilerplate, returns markdown — 2026-05-09