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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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=[],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user