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:
@@ -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:
|
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)
|
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,
|
file_read/list/write/session_read/session_search, shell_exec, claude_allow_dir,
|
||||||
cortex_restart/logs/status/update,
|
cortex_restart/logs/status/update,
|
||||||
task_list/create/update/complete, cron_list/add/remove/toggle,
|
task_list/create/update/complete, cron_list/add/remove/toggle,
|
||||||
reminders_add/list/remove/clear, scratch_read/write/append/clear,
|
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_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.
|
ae_task_list, agent_notes_read/write/append/clear, spawn_agent.
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI, APIConnectionError, APIStatusError
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from orchestrator_engine import OrchestrateCheckpoint, OrchestratorResult
|
from orchestrator_engine import OrchestrateCheckpoint, OrchestratorResult
|
||||||
@@ -286,7 +286,7 @@ async def _run_from_messages(
|
|||||||
if active_tools:
|
if active_tools:
|
||||||
call_kwargs["tools"] = active_tools
|
call_kwargs["tools"] = active_tools
|
||||||
call_kwargs["tool_choice"] = "auto"
|
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]
|
choice = response.choices[0]
|
||||||
msg = choice.message
|
msg = choice.message
|
||||||
@@ -345,7 +345,7 @@ async def _run_from_messages(
|
|||||||
conf_call: dict = {"model": model_name, "messages": messages, "tool_choice": "none"}
|
conf_call: dict = {"model": model_name, "messages": messages, "tool_choice": "none"}
|
||||||
if active_tools:
|
if active_tools:
|
||||||
conf_call["tools"] = 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 (
|
final_response = conf_resp.choices[0].message.content or (
|
||||||
"This action requires your explicit confirmation before it can proceed."
|
"This action requires your explicit confirmation before it can proceed."
|
||||||
)
|
)
|
||||||
@@ -386,6 +386,30 @@ async def _run_from_messages(
|
|||||||
return final_response, None
|
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(
|
def _build_client(
|
||||||
model_cfg: dict | None,
|
model_cfg: dict | None,
|
||||||
user_role: str = "user",
|
user_role: str = "user",
|
||||||
|
|||||||
@@ -82,11 +82,11 @@ Orchestrated sessions persist to history exactly like regular chat.
|
|||||||
|
|
||||||
### Available Tools
|
### 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 |
|
| 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` |
|
| **Files** | `file_read`, `file_list`, `file_write`, `session_read`, `session_search` |
|
||||||
| **Shell** | `shell_exec`, `claude_allow_dir` |
|
| **Shell** | `shell_exec`, `claude_allow_dir` |
|
||||||
| **System** | `cortex_restart`, `cortex_logs`, `cortex_status`, `cortex_update` |
|
| **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` |
|
| **Cron** | `cron_list`, `cron_add`, `cron_remove`, `cron_toggle` |
|
||||||
| **Reminders** | `reminders_add`, `reminders_list`, `reminders_remove`, `reminders_clear` |
|
| **Reminders** | `reminders_add`, `reminders_list`, `reminders_remove`, `reminders_clear` |
|
||||||
| **Scratchpad** | `scratch_read`, `scratch_write`, `scratch_append`, `scratch_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` |
|
| **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` |
|
| **Agent Notes** | `agent_notes_read`, `agent_notes_write`, `agent_notes_append`, `agent_notes_clear` |
|
||||||
| **Agents** | `spawn_agent` |
|
| **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
|
### Per-Role Tool Sets
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from google.genai import types
|
|||||||
|
|
||||||
# ── Callable imports ──────────────────────────────────────────────────────────
|
# ── 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 (
|
from tools.ae_knowledge import (
|
||||||
journal_list as _ae_journal_list,
|
journal_list as _ae_journal_list,
|
||||||
journal_search as _ae_journal_search,
|
journal_search as _ae_journal_search,
|
||||||
@@ -63,7 +63,7 @@ from tools.scratch import (
|
|||||||
scratch_append as _scratch_append,
|
scratch_append as _scratch_append,
|
||||||
scratch_clear as _scratch_clear,
|
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 (
|
from tools.agent_notes import (
|
||||||
agent_notes_read as _agent_notes_read,
|
agent_notes_read as _agent_notes_read,
|
||||||
agent_notes_write as _agent_notes_write,
|
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 — used by the Model Registry UI for grouped checkboxes ───
|
||||||
|
|
||||||
TOOL_CATEGORIES: dict[str, list[str]] = {
|
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"],
|
"Files": ["file_read", "file_list", "file_write", "session_read", "session_search"],
|
||||||
"Shell": ["shell_exec", "claude_allow_dir"],
|
"Shell": ["shell_exec", "claude_allow_dir"],
|
||||||
"System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"],
|
"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"],
|
"Cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"],
|
||||||
"Reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"],
|
"Reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"],
|
||||||
"Scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_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": [
|
"Aether Journals": [
|
||||||
"ae_journal_list", "ae_journal_search",
|
"ae_journal_list", "ae_journal_search",
|
||||||
"ae_journal_entries_list", "ae_journal_entry_read",
|
"ae_journal_entries_list", "ae_journal_entry_read",
|
||||||
@@ -117,6 +117,7 @@ _CALLABLES: dict[str, callable] = {
|
|||||||
"web_search": _web_search,
|
"web_search": _web_search,
|
||||||
"http_fetch": _http_fetch,
|
"http_fetch": _http_fetch,
|
||||||
"web_read": _web_read,
|
"web_read": _web_read,
|
||||||
|
"http_post": _http_post,
|
||||||
"ae_journal_list": _ae_journal_list,
|
"ae_journal_list": _ae_journal_list,
|
||||||
"ae_journal_search": _ae_journal_search,
|
"ae_journal_search": _ae_journal_search,
|
||||||
"ae_journal_entry_read": _ae_journal_entry_read,
|
"ae_journal_entry_read": _ae_journal_entry_read,
|
||||||
@@ -157,6 +158,7 @@ _CALLABLES: dict[str, callable] = {
|
|||||||
"email_send": _email_send,
|
"email_send": _email_send,
|
||||||
"nc_talk_send": _nc_talk_send,
|
"nc_talk_send": _nc_talk_send,
|
||||||
"web_push": _web_push,
|
"web_push": _web_push,
|
||||||
|
"nc_talk_history": _nc_talk_history,
|
||||||
"agent_notes_read": _agent_notes_read,
|
"agent_notes_read": _agent_notes_read,
|
||||||
"agent_notes_write": _agent_notes_write,
|
"agent_notes_write": _agent_notes_write,
|
||||||
"agent_notes_append": _agent_notes_append,
|
"agent_notes_append": _agent_notes_append,
|
||||||
@@ -181,6 +183,8 @@ TOOL_ROLES: dict[str, str] = {
|
|||||||
"spawn_agent": "admin",
|
"spawn_agent": "admin",
|
||||||
"email_send": "admin",
|
"email_send": "admin",
|
||||||
"nc_talk_send": "admin",
|
"nc_talk_send": "admin",
|
||||||
|
"http_post": "admin",
|
||||||
|
"nc_talk_history": "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tools that require explicit user confirmation before executing.
|
# Tools that require explicit user confirmation before executing.
|
||||||
@@ -191,6 +195,7 @@ CONFIRM_REQUIRED: set[str] = {
|
|||||||
"shell_exec",
|
"shell_exec",
|
||||||
"cron_remove",
|
"cron_remove",
|
||||||
"reminders_clear",
|
"reminders_clear",
|
||||||
|
"http_post",
|
||||||
}
|
}
|
||||||
|
|
||||||
_ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1}
|
_ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
from google.genai import types
|
from google.genai import types
|
||||||
from config import settings
|
from config import settings
|
||||||
from persona import get_user
|
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)."
|
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:
|
async def nc_talk_send(message: str) -> str:
|
||||||
"""Send a message to the user via their configured notification channel.
|
"""Send a message to the user via their configured notification channel.
|
||||||
|
|
||||||
@@ -145,4 +214,21 @@ DECLARATIONS = [
|
|||||||
required=["message"],
|
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 asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from google.genai import types
|
from google.genai import types
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from persona import get_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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}"
|
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 = [
|
DECLARATIONS = [
|
||||||
types.FunctionDeclaration(
|
types.FunctionDeclaration(
|
||||||
name="web_search",
|
name="web_search",
|
||||||
@@ -169,4 +246,22 @@ DECLARATIONS = [
|
|||||||
required=["url"],
|
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"],
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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
|
**Active users / personas:** scott/inara, holly/tina, brian/wintermute
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -45,6 +45,9 @@
|
|||||||
- ✅ Sub-agent spawning — `spawn_agent` tool; per-host concurrency limit; Gemini API + local OpenAI backends
|
- ✅ 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
|
- ✅ Web content extraction — `web_read` via trafilatura; strips ads/nav/boilerplate; 128K cap
|
||||||
- ✅ Session log reader — `session_read(date)` tool; complements `session_search`
|
- ✅ 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)
|
- [ ] Knowledge import — markdown → AE Journals (import script)
|
||||||
- [ ] Dev agent pipeline — specialist agents + supervisor + approval gate
|
- [ ] Dev agent pipeline — specialist agents + supervisor + approval gate
|
||||||
- [ ] Gitea webhook integration + Actions CI
|
- [ ] Gitea webhook integration + Actions CI
|
||||||
|
|||||||
@@ -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 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] 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
|
- [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
|
- [ ] Test end-to-end with Gemma 4 E4B and 26B A4B on scott_gaming
|
||||||
- [ ] Review `ARCH__FUTURE.md` agent architecture ideas before finalising design
|
- [ ] Review `ARCH__FUTURE.md` agent architecture ideas before finalising design
|
||||||
- Reference: `docs/OPEN_WEBUI_API.md`, `documentation/ARCH__FUTURE.md` §1
|
- 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
|
- Supports `local_openai` and `gemini_api` model types; returns error string for others
|
||||||
- Admin-only tool (powerful — can spawn arbitrarily long sub-tasks)
|
- Admin-only tool (powerful — can spawn arbitrarily long sub-tasks)
|
||||||
- Host UI: "Max parallel" number input in host edit/add forms
|
- Host UI: "Max parallel" number input in host edit/add forms
|
||||||
- [ ] **`http_post`** — POST to external URLs
|
- [x] **`http_post`** — POST to external URLs — 2026-05-09
|
||||||
- Params: `url: str`, `body: dict | str`, `headers: dict | None`
|
- Params: `url: str`, `body: str`, `headers: dict | None`, `max_chars: int`
|
||||||
- Per-user host allowlist in `home/{user}/http_allowlist.json` (same pattern as email)
|
- Per-user URL prefix allowlist in `home/{user}/http_allowlist.json` (JSON array of prefixes)
|
||||||
- Default: blocked unless URL host matches an allowlist entry
|
- Default: blocked if no allowlist or URL doesn't match any prefix
|
||||||
- Confirm-required for safety
|
- Admin-only, confirm-required
|
||||||
- [ ] **`nc_talk_history`** — read recent Talk messages before replying
|
- [x] **`nc_talk_history`** — read recent Talk messages — 2026-05-09
|
||||||
- Params: `conversation_token: str`, `limit: int = 20`
|
- Params: `conversation_token: str` (optional, defaults to notification_room), `limit: int = 20`
|
||||||
- Returns last N messages with sender + timestamp
|
- Returns last N messages with sender + timestamp, chronological order
|
||||||
- Admin-only (requires NC Talk API credentials from channels.json)
|
- Admin-only; requires `nc_username` and `nc_app_password` in channels.json under `nextcloud`
|
||||||
- [ ] **`http_post`** — POST to external URLs with allowlist
|
|
||||||
- [ ] **`task_list` priority filter** — add `priority` param alongside existing `status`
|
- [ ] **`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] **`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
|
- [x] **`web_read(url, max_chars=16000)`** — clean article extraction via trafilatura; strips ads/nav/boilerplate, returns markdown — 2026-05-09
|
||||||
|
|||||||
Reference in New Issue
Block a user