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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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