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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-09 13:38:38 -04:00
parent b9a78819ac
commit 7b443b40a4
9 changed files with 244 additions and 26 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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}

View File

@@ -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=[],
),
),
]

View File

@@ -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"],
),
),
]