Compare commits

...

5 Commits

Author SHA1 Message Date
Scott Idem
47d23a7b2f feat: per-model max_rounds for Gemini orchestrator engine
Mirrors the pattern already in openai_orchestrator.py. The Gemini engine
was still hardcoded to the global orchestrator_max_rounds setting.

- orchestrator_engine.py: max_rounds param on run() and _run_from_contents();
  effective_limit = min(per_model_limit, global_limit); stored in checkpoint
  so resume() respects it across confirmation gates
- routers/orchestrator.py: passes orch_model.get("max_rounds") to run()
- tools/agents.py: passes model_cfg.get("max_rounds") for gemini_api spawns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:54:37 -04:00
Scott Idem
09d775b47b feat: spawn_agent tool + host max_concurrent + docs
Adds a synchronous sub-agent spawning tool that lets the orchestrator
delegate tasks to a specific role's model and tool set.

- cortex/tools/agents.py: spawn_agent(task, role, tier, timeout, max_rounds)
  - Supports local_openai and gemini_api model types
  - Per-host asyncio semaphore (keyed by host_id or model type)
  - asyncio.wait_for() enforces timeout; admin-only tool
- cortex/model_registry.py: max_concurrent field in host schema (default 3,
  clamped 1-20); backfilled on _normalize() for existing hosts
- cortex/routers/local_llm.py + local_llm.html: "Max parallel" number input
  in host add/edit forms
- cortex/tools/__init__.py: spawn_agent registered in TOOL_CATEGORIES["Agents"],
  _CALLABLES, TOOL_ROLES (admin), and _ALL_DECLARATIONS
- Docs: TOOLS.md count 44→45, spawn_agent section; HELP.md tool table updated;
  ARCH__FUTURE.md Round 2 completed items; TODO__Agents.md spawn_agent checked;
  CLAUDE.md tool count and list updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:48:21 -04:00
Scott Idem
6ad7597db8 feat: per-role inject_datetime toggle for system prompt
Each role can now disable the current date/time header injected into the
system prompt. Default is true (all existing roles unchanged). Useful for
pure processing roles (summarizer, classifier, translator) where temporal
context is irrelevant or could cause unexpected model behavior.

Changes:
- model_registry: set_role_config/get_role_config gain inject_datetime field
- context_loader: load_context gains inject_datetime param (default True)
- orchestrator router: passes inject_datetime from role_cfg to load_context
- local_llm router: reads inject_datetime from POST body, passes to registry;
  role_config_data_js includes the field
- local_llm.html: checkbox in role config panel; populate on open, save on submit

Session logs still timestamp every turn (HH:MM header in YYYY-MM-DD.md files)
regardless of this setting — the toggle only affects the system prompt header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:53:35 -04:00
Scott Idem
8e512d4e11 feat: reminders due-date support + context filtering
reminders_add now accepts optional due: YYYY-MM-DD parameter.
Due date stored as first line of section body in REMINDERS.md.

context_loader.py calls load_due_reminders() instead of loading REMINDERS.md
wholesale — future-dated reminders are suppressed in the system prompt until
their date arrives. Undated reminders always surface (backward compatible).

reminders_list shows due status per entry: [OVERDUE by N days], [due TODAY],
or [due: YYYY-MM-DD] for future items. All reminders visible via the tool
regardless of date; only context surfacing is filtered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:46:45 -04:00
Scott Idem
750cde489d feat: session_search tool + tool expansion docs update
session_search (tools/files.py):
- Full-text search across past session logs, exposed to the orchestrator
- Params: query (required), limit (default 5, max 20)
- Returns dated excerpts, newest first; own sessions only via ContextVars
- User-level — no TOOL_ROLES gating needed
- Registered in __init__.py callables + TOOL_CATEGORIES["Files"]

ARCH__FUTURE.md §2: updated tool count to 44, marked prior tools complete,
added Round 2 planned tools table (session_search now done, reminders due dates,
http_post, nc_talk_history, task_list priority filter, http_fetch max_chars),
noted datetime_now is not needed (already in system prompt via context_loader)

TODO__Agents.md: session_search checked off, Round 2 task list added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:41:26 -04:00
15 changed files with 572 additions and 105 deletions

View File

@@ -255,14 +255,14 @@ Cortex is running and stable. All channels are live:
Active users: scott (inara), holly (tina), brian (wintermute)
**40 orchestrator tools:** web_search, http_fetch,
file_read/list/write, shell_exec, claude_allow_dir,
**45 orchestrator tools:** web_search, http_fetch,
file_read/list/write/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,
ae_journal_list/search/entries_list/entry_read/entry_create/entry_update/entry_disable/entry_append/entry_prepend,
ae_task_list.
ae_task_list, agent_notes_read/write/append/clear, spawn_agent.
See `documentation/TODO__Agents.md` for the active task list.
See `documentation/ROADMAP.md` for phases and what's next.

View File

@@ -2,6 +2,7 @@ from datetime import datetime
from pathlib import Path
from persona import persona_path
from tools.reminders import load_due_reminders
_STATIC_DIR = Path(__file__).parent / "static"
@@ -19,6 +20,7 @@ def load_context(
include_mid: bool = True,
include_short: bool = True,
role_append: str = "",
inject_datetime: bool = True,
) -> str:
"""
Build the system-prompt context block for a given tier and memory toggles.
@@ -37,9 +39,10 @@ def load_context(
inara_dir = persona_path()
parts = []
# ── 0. Current date and time (always — injected first so it's prominent) ──
now = datetime.now().astimezone()
parts.append(f"--- System ---\nCurrent date and time: {now.strftime('%A, %Y-%m-%d at %I:%M %p %Z')}")
# ── 0. Current date and time (per-role toggle — injected first so it's prominent) ──
if inject_datetime:
now = datetime.now().astimezone()
parts.append(f"--- System ---\nCurrent date and time: {now.strftime('%A, %Y-%m-%d at %I:%M %p %Z')}")
# ── 1. Core identity (always) ──────────────────────────────────
for filename in _CORE:
@@ -80,12 +83,11 @@ def load_context(
parts.append(f"--- HELP.md ---\n{help_path.read_text()}")
# ── 4. Pending reminders (tier 2+) ────────────────────────────
# Written by cron jobs; cleared by Inara after acting on them.
reminders_path = inara_dir / "REMINDERS.md"
if reminders_path.exists() and reminders_path.stat().st_size > 10:
content = reminders_path.read_text().strip()
if content:
parts.append(f"--- REMINDERS.md ---\n{content}")
# Only due and undated reminders are surfaced — future-dated ones
# are stored in REMINDERS.md but suppressed until their date arrives.
content = load_due_reminders()
if content:
parts.append(f"--- REMINDERS.md ---\n{content}")
# ── 5. Tiered memory — long → mid → short ─────────────────────
# Short is last so it sits closest to the conversation turn.

View File

@@ -189,6 +189,7 @@ def _normalize(data: dict) -> dict:
"""Back-fill missing fields introduced by schema additions."""
for h in data.get("hosts", []):
h.setdefault("host_type", "openwebui")
h.setdefault("max_concurrent", 3)
data.setdefault("providers", _default_providers())
data["providers"].setdefault("anthropic", {"credentials": [{"id": "cli", "label": "Claude CLI (OAuth)", "type": "cli"}]})
data["providers"].setdefault("google", {"accounts": []})
@@ -416,17 +417,25 @@ def get_best_local_model(username: str, role: str = "chat") -> dict | None:
return None
def set_role_config(username: str, role: str, system_append: str, tools: list[str] | None) -> None:
"""Save system_append and tools allow-list for a role.
def set_role_config(
username: str,
role: str,
system_append: str,
tools: list[str] | None,
inject_datetime: bool = True,
) -> None:
"""Save system_append, tools allow-list, and inject_datetime flag for a role.
tools=None clears the allow-list (role uses all accessible tools).
tools=[] would mean no tools at all — validate in the caller if that's undesired.
inject_datetime=False suppresses the current date/time from the system prompt
for this role — useful for pure processing roles (summarizer, classifier, etc.).
"""
data = _load(username)
roles = data.setdefault("roles", {})
if role not in roles:
roles[role] = {}
roles[role]["system_append"] = system_append.strip()
roles[role]["inject_datetime"] = inject_datetime
if tools is None:
roles[role].pop("tools", None)
else:
@@ -436,17 +445,19 @@ def set_role_config(username: str, role: str, system_append: str, tools: list[st
def get_role_config(username: str, role: str) -> dict:
"""
Return supplemental config for a role: system_append and tools.
Return supplemental config for a role: system_append, tools, and inject_datetime.
Both keys are optional in the registry — missing means "use defaults":
system_append: str — appended to the system prompt for this role
All keys are optional in the registry — missing means "use defaults":
system_append: str — appended to the system prompt for this role
tools: list[str] | None — explicit tool allow-list (None = no restriction)
inject_datetime: bool — whether to inject current date/time (default True)
"""
registry = _load(username)
role_cfg = registry.get("roles", {}).get(role, {})
return {
"system_append": role_cfg.get("system_append", ""),
"tools": role_cfg.get("tools") or None,
"system_append": role_cfg.get("system_append", ""),
"tools": role_cfg.get("tools") or None,
"inject_datetime": role_cfg.get("inject_datetime", True),
}
@@ -595,17 +606,20 @@ def remove_google_account(username: str, account_id: str) -> bool:
def save_host(username: str, host_id: str | None,
label: str, api_url: str, api_key: str,
host_type: str = "openwebui") -> str:
host_type: str = "openwebui",
max_concurrent: int = 3) -> str:
"""Create or update a host. Returns the host ID."""
data = _load(username)
host_type = host_type if host_type in ("openwebui", "openai") else "openwebui"
max_concurrent = max(1, min(int(max_concurrent), 20))
if host_id:
for h in data["hosts"]:
if h["id"] == host_id:
h["label"] = label.strip()
h["api_url"] = api_url.strip()
h["host_type"] = host_type
h["label"] = label.strip()
h["api_url"] = api_url.strip()
h["host_type"] = host_type
h["max_concurrent"] = max_concurrent
if api_key.strip():
h["api_key"] = api_key.strip()
_save(username, data)
@@ -614,11 +628,12 @@ def save_host(username: str, host_id: str | None,
host_id = secrets.token_hex(4)
data["hosts"].append({
"id": host_id,
"label": label.strip(),
"api_url": api_url.strip(),
"api_key": api_key.strip(),
"host_type": host_type,
"id": host_id,
"label": label.strip(),
"api_url": api_url.strip(),
"api_key": api_key.strip(),
"host_type": host_type,
"max_concurrent": max_concurrent,
})
_save(username, data)
return host_id

View File

@@ -91,6 +91,7 @@ class OrchestrateCheckpoint:
confirm_allow: frozenset = field(default_factory=frozenset)
confirm_deny: frozenset = field(default_factory=frozenset)
rounds_used: int = 0
max_rounds: int | None = None
@dataclass
@@ -114,6 +115,7 @@ async def run(
tool_list: list[str] | None = None,
confirm_allow: set[str] | None = None,
confirm_deny: set[str] | None = None,
max_rounds: int | None = None,
) -> OrchestratorResult:
"""
Run the full orchestration loop for a task.
@@ -173,6 +175,7 @@ async def run(
confirm_deny=_confirm_deny,
starting_round=0,
gemini_api_key=api_key,
max_rounds=max_rounds,
)
if checkpoint:
@@ -248,6 +251,7 @@ async def resume(checkpoint: OrchestrateCheckpoint, confirmed: bool) -> Orchestr
confirm_deny=checkpoint.confirm_deny,
starting_round=checkpoint.rounds_used,
gemini_api_key=api_key,
max_rounds=checkpoint.max_rounds,
)
if new_checkpoint:
@@ -289,14 +293,17 @@ async def _run_from_contents(
starting_round: int = 0,
gemini_api_key: str | None = None,
tool_list: list[str] | None = None,
max_rounds: int | None = None,
) -> tuple[str, OrchestrateCheckpoint | None]:
"""
Run the ReAct loop from the current contents state.
Returns (gemini_summary, checkpoint) — checkpoint is set if confirmation is needed.
"""
gemini_summary = ""
per_model_limit = max_rounds or settings.orchestrator_max_rounds
effective_limit = min(per_model_limit, settings.orchestrator_max_rounds)
for round_num in range(starting_round, settings.orchestrator_max_rounds):
for round_num in range(starting_round, effective_limit):
logger.info("Orchestrator round %d for task: %.80s", round_num + 1, task)
response = await asyncio.to_thread(
@@ -400,15 +407,16 @@ async def _run_from_contents(
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
rounds_used=round_num + 2,
max_rounds=max_rounds,
)
return gemini_summary, checkpoint
contents.append(types.Content(role="user", parts=response_parts))
else:
logger.warning("Orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds)
logger.warning("Orchestrator hit max rounds (%d)", effective_limit)
gemini_summary = (
f"Reached the tool iteration limit ({settings.orchestrator_max_rounds} rounds). "
f"Reached the tool iteration limit ({effective_limit} rounds). "
"Here is what was gathered so far:\n\n"
+ "\n\n".join(f"**{t['tool']}**: {t['result'][:500]}" for t in tool_call_log)
)

View File

@@ -114,6 +114,11 @@ def _render(username: str, success: str = "", error: str = "") -> str:
<option value="openai"{ai}>OpenAI-compatible (OpenRouter, etc.)</option>
</select>
</div>
<div class="field" style="flex:0 0 auto; width:6rem">
<label>Max parallel</label>
<input type="number" name="max_concurrent" min="1" max="20"
value="{h.get('max_concurrent', 3)}" style="width:100%">
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
@@ -313,6 +318,14 @@ def _render(username: str, success: str = "", error: str = "") -> str:
f'<textarea class="rcp-textarea" data-role="{role}" rows="3" '
f'placeholder="Extra instructions injected into the system prompt when this role is active…"></textarea>'
f'</div>'
f'<div class="rcp-field rcp-field-inline">'
f'<label class="rcp-check">'
f'<input type="checkbox" class="rcp-datetime-cb" data-role="{role}" checked>'
f' Inject current date &amp; time into system prompt'
f'</label>'
f'<span class="rcp-hint" style="display:block;margin-top:0.2rem">'
f'Disable for pure processing roles (summarizer, classifier, translator)</span>'
f'</div>'
f'<div class="rcp-field">'
f'<label class="rcp-label">Tool allow-list '
f'<span class="rcp-hint">— all checked means no restriction (use all accessible tools)</span></label>'
@@ -332,8 +345,9 @@ def _render(username: str, success: str = "", error: str = "") -> str:
role_config_data_js = _json.dumps({
role: {
"system_append": roles.get(role, {}).get("system_append", ""),
"tools": roles.get(role, {}).get("tools") or None,
"system_append": roles.get(role, {}).get("system_append", ""),
"tools": roles.get(role, {}).get("tools") or None,
"inject_datetime": roles.get(role, {}).get("inject_datetime", True),
}
for role in app_settings.get_defined_roles()
})
@@ -412,19 +426,20 @@ async def remove_google_account(request: Request, account_id: str):
@router.post("/settings/local/host", include_in_schema=False)
async def save_host(
request: Request,
host_id: str = Form(""),
label: str = Form(""),
api_url: str = Form(""),
api_key: str = Form(""),
host_type: str = Form("openwebui"),
request: Request,
host_id: str = Form(""),
label: str = Form(""),
api_url: str = Form(""),
api_key: str = Form(""),
host_type: str = Form("openwebui"),
max_concurrent: int = Form(3),
):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not api_url.strip():
return HTMLResponse(_render(username, error="API URL is required."))
reg.save_host(username, host_id or None, label, api_url, api_key, host_type)
reg.save_host(username, host_id or None, label, api_url, api_key, host_type, max_concurrent)
return HTMLResponse(_render(username, success="Host saved."))
@@ -574,10 +589,11 @@ async def set_role(request: Request) -> JSONResponse:
@router.post("/api/models/role-config")
async def set_role_config(request: Request) -> JSONResponse:
"""AJAX: save system_append and tool allow-list for a role.
"""AJAX: save system_append, tool allow-list, and inject_datetime flag for a role.
Body: {"role": "coder", "system_append": "...", "tools": ["web_search", ...] | null}
Body: {"role": "coder", "system_append": "...", "tools": [...] | null, "inject_datetime": true}
tools=null clears the allow-list (role uses all accessible tools).
inject_datetime=false suppresses the date/time header for pure processing roles.
"""
username = _get_user(request)
if not username:
@@ -587,18 +603,19 @@ async def set_role_config(request: Request) -> JSONResponse:
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
role = body.get("role", "").strip()
system_append = body.get("system_append", "")
tools = body.get("tools") # list[str] or None
role = body.get("role", "").strip()
system_append = body.get("system_append", "")
tools = body.get("tools") # list[str] or None
inject_datetime = body.get("inject_datetime", True)
if not role:
return JSONResponse({"error": "role is required"}, status_code=400)
if tools is not None and not isinstance(tools, list):
return JSONResponse({"error": "tools must be a list or null"}, status_code=400)
reg.set_role_config(username, role, system_append, tools)
logger.info("role config saved: %s %s (tools=%s)", username, role,
len(tools) if tools is not None else "all")
reg.set_role_config(username, role, system_append, tools, inject_datetime=bool(inject_datetime))
logger.info("role config saved: %s %s (tools=%s inject_datetime=%s)",
username, role, len(tools) if tools is not None else "all", inject_datetime)
return JSONResponse({"ok": True})

View File

@@ -203,6 +203,7 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
include_mid=req.include_mid,
include_short=req.include_short,
role_append=role_cfg.get("system_append", ""),
inject_datetime=role_cfg.get("inject_datetime", True),
)
session_id = req.session_id or generate_session_id()
@@ -246,6 +247,7 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
tool_list=tool_list,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
max_rounds=orch_model.get("max_rounds") if orch_model else None,
)
if result.checkpoint:

View File

@@ -82,12 +82,12 @@ Orchestrated sessions persist to history exactly like regular chat.
### Available Tools
40 tools across 11 categories. Each tool schema is sent to the model on every orchestrated call — fewer active tools means fewer tokens per call.
45 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` |
| **Files** | `file_read`, `file_list`, `file_write` |
| **Files** | `file_read`, `file_list`, `file_write`, `session_search` |
| **Shell** | `shell_exec`, `claude_allow_dir` |
| **System** | `cortex_restart`, `cortex_logs`, `cortex_status`, `cortex_update` |
| **Tasks** | `task_list`, `task_create`, `task_update`, `task_complete` |
@@ -97,8 +97,9 @@ Orchestrated sessions persist to history exactly like regular chat.
| **Notifications** | `web_push`, `email_send`, `nc_talk_send` |
| **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, and some Notification tools are **admin-only** and not visible to regular users.
File, Shell, System, Agents, and some Notification tools are **admin-only** and not visible to regular users.
### Per-Role Tool Sets
@@ -277,6 +278,8 @@ Leave all slots empty to use the server default.
**Per-role tool sets:** Expand any role card to configure which tool categories the orchestrator can use when that role is active. Unchecked categories are hidden from the model entirely — reducing token overhead on every orchestrated call. Leaving all categories unchecked means all tools the user has access to are available (the default).
**Inject timestamp:** Each role card has an "Inject current date & time into system prompt" checkbox (default on). Disable it for pure processing roles (summarizer, classifier, translator) that don't need clock awareness.
---
## Nextcloud Talk Bot

View File

@@ -1,6 +1,6 @@
# Tool Reference
> This reference covers all 44 orchestrator tools available when the ⚡ toggle is on.
> This reference covers all 45 orchestrator tools available when the ⚡ toggle is on.
> Tools are invoked automatically by the orchestrator — you don't call them directly.
¹ **Admin only** — requires the `admin` role. Invisible to regular users.
@@ -113,3 +113,11 @@ Private, durable notes visible only to the orchestrator — not surfaced to user
| `agent_notes_write` | Overwrite the notes file completely |
| `agent_notes_append` | Append a timestamped entry (keeps last 3 backups automatically) |
| `agent_notes_clear` | Erase all notes (backs up first) |
## Agents ¹
Spawn sub-agents that run their own tool loop using a specific role's model and tools.
| Tool | What it does |
|---|---|
| `spawn_agent` ¹ | Spawn a sub-agent synchronously — blocks until the task completes or times out. Params: `task`, `role` (default `chat`), `tier` (14, default 1), `timeout` seconds, `max_rounds` override. Only works with `local_openai` and `gemini_api` models. |

View File

@@ -403,6 +403,10 @@
<option value="openai">OpenAI-compatible (OpenRouter, etc.)</option>
</select>
</div>
<div class="field" style="flex:0 0 auto; width:6rem">
<label>Max parallel</label>
<input type="number" name="max_concurrent" min="1" max="20" value="3" style="width:100%">
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-primary btn-sm">Add Host</button>
@@ -627,6 +631,9 @@
if (!panel) return;
// Populate textarea
panel.querySelector('.rcp-textarea').value = cfg.system_append || '';
// Inject datetime checkbox (default true if not set)
const dtCb = panel.querySelector('.rcp-datetime-cb');
if (dtCb) dtCb.checked = cfg.inject_datetime !== false;
// Build tool checklist
buildToolChecklist(role, cfg.tools || null);
panel.classList.add('open');
@@ -665,7 +672,9 @@
const role = btn.dataset.role;
const panel = document.getElementById(`rcp-${role}`);
const ta = panel.querySelector('.rcp-textarea');
const checks = [...panel.querySelectorAll('.rcp-check input[type=checkbox]')];
const dtCb = panel.querySelector('.rcp-datetime-cb');
const inject_datetime = dtCb ? dtCb.checked : true;
const checks = [...panel.querySelectorAll('.rcp-tools input[type=checkbox]')];
const allChecked = checks.every(c => c.checked);
const someChecked = checks.some(c => c.checked);
const tools = allChecked ? null : (someChecked ? checks.filter(c => c.checked).map(c => c.value) : []);
@@ -675,7 +684,7 @@
const res = await fetch('/api/models/role-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role, system_append: ta.value, tools}),
body: JSON.stringify({role, system_append: ta.value, tools, inject_datetime}),
});
const data = await res.json();
if (data.ok) {
@@ -683,6 +692,7 @@
if (!ROLE_CONFIG_DATA[role]) ROLE_CONFIG_DATA[role] = {};
ROLE_CONFIG_DATA[role].system_append = ta.value;
ROLE_CONFIG_DATA[role].tools = tools;
ROLE_CONFIG_DATA[role].inject_datetime = inject_datetime;
showToast(`${role} config saved`);
closeRolePanel(role);
} else {

View File

@@ -30,7 +30,7 @@ from tools.ae_knowledge import (
journal_entry_prepend as _ae_journal_entry_prepend,
)
from tools.ae_tasks import task_list as _ae_task_list
from tools.files import file_read as _file_read, file_list as _file_list, file_write as _file_write
from tools.files import file_read as _file_read, file_list as _file_list, file_write as _file_write, session_search as _session_search
from tools.system import (
shell_exec as _shell_exec,
claude_allow_dir as _claude_allow_dir,
@@ -70,6 +70,7 @@ from tools.agent_notes import (
agent_notes_append as _agent_notes_append,
agent_notes_clear as _agent_notes_clear,
)
from tools.agents import spawn_agent as _spawn_agent
# ── Declaration imports ───────────────────────────────────────────────────────
@@ -84,12 +85,13 @@ import tools.reminders as _mod_reminders
import tools.scratch as _mod_scratch
import tools.notify as _mod_notify
import tools.agent_notes as _mod_agent_notes
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"],
"Files": ["file_read", "file_list", "file_write"],
"Files": ["file_read", "file_list", "file_write", "session_search"],
"Shell": ["shell_exec", "claude_allow_dir"],
"System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"],
"Tasks": ["task_list", "task_create", "task_update", "task_complete"],
@@ -106,6 +108,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
],
"Aether Tasks": ["ae_task_list"],
"Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
"Agents": ["spawn_agent"],
}
# ── Callable registry ─────────────────────────────────────────────────────────
@@ -126,6 +129,7 @@ _CALLABLES: dict[str, callable] = {
"file_read": _file_read,
"file_list": _file_list,
"file_write": _file_write,
"session_search": _session_search,
"shell_exec": _shell_exec,
"claude_allow_dir": _claude_allow_dir,
"cortex_restart": _cortex_restart,
@@ -155,6 +159,7 @@ _CALLABLES: dict[str, callable] = {
"agent_notes_write": _agent_notes_write,
"agent_notes_append": _agent_notes_append,
"agent_notes_clear": _agent_notes_clear,
"spawn_agent": _spawn_agent,
}
# ── Role-based access control ─────────────────────────────────────────────────
@@ -171,6 +176,7 @@ TOOL_ROLES: dict[str, str] = {
"file_list": "admin",
"file_write": "admin",
"ae_task_list": "admin",
"spawn_agent": "admin",
"email_send": "admin",
"nc_talk_send": "admin",
}
@@ -207,6 +213,7 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
+ _mod_ae_knowledge.DECLARATIONS
+ _mod_ae_tasks.DECLARATIONS
+ _mod_agent_notes.DECLARATIONS
+ _mod_agents.DECLARATIONS
)
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)

205
cortex/tools/agents.py Normal file
View File

@@ -0,0 +1,205 @@
"""
Agent spawning tool — lets the orchestrator launch sub-agents synchronously.
Sub-agents run using the model assigned to the specified role. The call blocks
until the sub-agent completes or times out.
Supported model types: local_openai, gemini_api.
claude_cli / gemini_cli are chat-only and do not support sub-agent tool loops.
"""
import asyncio
import logging
from google.genai import types
logger = logging.getLogger(__name__)
# Per-host semaphores — keyed by "host:<host_id>" or "type:<model_type>"
# Created lazily on first use; never deleted (module-level singletons)
_semaphores: dict[str, asyncio.Semaphore] = {}
_sem_lock = asyncio.Lock()
async def _get_semaphore(key: str, max_concurrent: int) -> asyncio.Semaphore:
"""Return (or create) the semaphore for a given host/type key."""
async with _sem_lock:
if key not in _semaphores:
_semaphores[key] = asyncio.Semaphore(max_concurrent)
return _semaphores[key]
async def spawn_agent(
task: str,
role: str = "chat",
tier: int = 1,
timeout: int = 120,
max_rounds: int | None = None,
) -> str:
"""
Spawn a sub-agent to complete a task synchronously.
The sub-agent uses the model and tools assigned to the given role. Returns
the sub-agent's response as a string.
"""
import model_registry
from context_loader import load_context
from auth_utils import get_user_role, get_tool_policy
from persona import get_user
user = get_user() or "scott"
role_cfg = model_registry.get_role_config(user, role)
model_cfg = model_registry.get_model_for_role(user, role)
if not model_cfg:
return f"spawn_agent: no model configured for role '{role}'"
model_type = model_cfg.get("type", "unknown")
if model_type not in ("local_openai", "gemini_api"):
return (
f"spawn_agent: model type '{model_type}' does not support tool-enabled sub-agents. "
f"Assign a local_openai or gemini_api model to role '{role}'."
)
# Determine concurrency key and semaphore limit
host_id = model_cfg.get("host_id")
if host_id:
registry = model_registry.get_registry(user)
host = next((h for h in registry.get("hosts", []) if h["id"] == host_id), None)
max_concurrent = (host or {}).get("max_concurrent", 3)
sem_key = f"host:{host_id}"
else:
max_concurrent = 5 if model_type == "gemini_api" else 3
sem_key = f"type:{model_type}"
sem = await _get_semaphore(sem_key, max_concurrent)
system_prompt = load_context(
tier=tier,
include_long=(tier >= 2),
include_mid=(tier >= 2),
include_short=(tier >= 2),
role_append=role_cfg.get("system_append", ""),
inject_datetime=role_cfg.get("inject_datetime", True),
)
user_role = get_user_role(user)
tool_list = role_cfg.get("tools")
policy = get_tool_policy(user)
confirm_allow = set(policy.get("allow", []))
confirm_deny = set(policy.get("deny", []))
if max_rounds is not None:
model_cfg = dict(model_cfg)
model_cfg["max_rounds"] = max_rounds
async def _run() -> str:
if model_type == "local_openai":
import openai_orchestrator
result = await openai_orchestrator.run(
task=task,
system_prompt=system_prompt,
model_cfg=model_cfg,
respond_with_final=True,
user_role=user_role,
tool_list=tool_list,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
)
if result.checkpoint:
return (
"Sub-agent requires user confirmation — "
"confirmation gates are not supported inside spawn_agent. "
"Pre-allow the tool in the user's tool policy or use a different role."
)
return result.response or "(sub-agent returned no output)"
# gemini_api
import orchestrator_engine
from auth_utils import get_user_gemini_key
gemini_key = model_cfg.get("api_key") or get_user_gemini_key(user)
result = await orchestrator_engine.run(
task=task,
system_prompt=system_prompt,
session_messages=None,
respond_with_claude=True,
gemini_api_key=gemini_key,
model_name=model_cfg.get("model_name"),
response_role=role,
user_role=user_role,
tool_list=tool_list,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
max_rounds=model_cfg.get("max_rounds"),
)
if result.checkpoint:
return (
"Sub-agent requires user confirmation — "
"confirmation gates are not supported inside spawn_agent."
)
return result.response or "(sub-agent returned no output)"
async with sem:
try:
logger.info(
"spawn_agent: role=%s tier=%d timeout=%ds task=%.80s",
role, tier, timeout, task,
)
response = await asyncio.wait_for(_run(), timeout=float(timeout))
logger.info("spawn_agent: done role=%s response=%d chars", role, len(response))
return response
except asyncio.TimeoutError:
logger.warning("spawn_agent: timed out after %ds role=%s", timeout, role)
return f"Sub-agent timed out after {timeout}s (role={role})"
except Exception as e:
logger.exception("spawn_agent: failed role=%s", role)
return f"Sub-agent error ({role}): {e}"
DECLARATIONS = [
types.FunctionDeclaration(
name="spawn_agent",
description=(
"Spawn a sub-agent to complete a task synchronously. "
"The sub-agent uses the model and tool set assigned to the given role. "
"Use for processing pipelines, parallel analysis, or delegating "
"specialized work (research, coding, data migration, etc.)."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"task": types.Schema(
type=types.Type.STRING,
description="The complete task description for the sub-agent.",
),
"role": types.Schema(
type=types.Type.STRING,
description=(
"Role determining the model and tools. "
"E.g. 'research' for web lookups, 'coder' for code tasks, "
"'distill' for summarization. Defaults to 'chat'."
),
),
"tier": types.Schema(
type=types.Type.INTEGER,
description=(
"Context tier: 1 = minimal (fast, identity only), "
"2 = standard (+ memory), 3 = + last 2 session logs. "
"Use 1 for pure processing tasks."
),
),
"timeout": types.Schema(
type=types.Type.INTEGER,
description="Max seconds to wait (default 120).",
),
"max_rounds": types.Schema(
type=types.Type.INTEGER,
description="Override max tool-loop iterations for this call.",
),
},
required=["task"],
),
)
]

View File

@@ -1,13 +1,15 @@
"""
File read tool — restricted to known-safe directory roots.
File read/write/search tools — restricted to known-safe directory roots.
Lets the orchestrator read local files (documentation, notes, config references)
without exposing arbitrary filesystem access. All paths are resolved and checked
against an allowlist of roots before any read is performed.
and search past session logs without exposing arbitrary filesystem access.
All paths are resolved and checked against an allowlist of roots before any
read or write is performed.
"""
import asyncio
import logging
import re
from pathlib import Path
from google.genai import types
@@ -225,6 +227,55 @@ def _sync_file_write(path: str, content: str, mode: str) -> str:
return f"Write error: {e}"
_SEARCH_EXCERPT_CHARS = 150
async def session_search(query: str, limit: int = 5) -> str:
"""Search past session logs for a keyword or phrase.
Returns up to `limit` matching excerpts with session dates, newest first.
Only searches the current user's own sessions (per-persona isolation via ContextVars).
"""
return await asyncio.to_thread(_sync_session_search, query, limit)
def _sync_session_search(query: str, limit: int) -> str:
from persona import persona_path
sessions_dir = persona_path() / "sessions"
if not sessions_dir.exists():
return "No session logs found."
limit = max(1, min(limit, 20))
pattern = re.compile(re.escape(query), re.IGNORECASE)
session_files = sorted(sessions_dir.glob("*.md"), reverse=True)
matches = []
for sf in session_files:
if len(matches) >= limit:
break
try:
text = sf.read_text()
except OSError:
continue
for m in pattern.finditer(text):
if len(matches) >= limit:
break
start = max(0, m.start() - _SEARCH_EXCERPT_CHARS)
end = min(len(text), m.end() + _SEARCH_EXCERPT_CHARS)
excerpt = text[start:end].strip()
if start > 0:
excerpt = "" + excerpt
if end < len(text):
excerpt = excerpt + ""
matches.append(f"[{sf.stem}] {excerpt}")
if not matches:
return f"No matches for '{query}' across {len(session_files)} session logs."
header = f"Session search: '{query}'{len(matches)} match(es) across {len(session_files)} logs\n"
return header + "\n\n".join(matches)
DECLARATIONS = [
types.FunctionDeclaration(
name="file_read",
@@ -278,4 +329,22 @@ DECLARATIONS = [
required=["path", "content"],
),
),
types.FunctionDeclaration(
name="session_search",
description=(
"Search past conversation session logs for a keyword or phrase. "
"Use this to recall what was discussed in previous sessions — "
"e.g. 'what did we decide about X?', 'when did we set up Y?'. "
"Returns matching excerpts with session dates, newest first. "
"Only searches this user's own sessions."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"query": types.Schema(type=types.Type.STRING, description="Keyword or phrase to search for"),
"limit": types.Schema(type=types.Type.INTEGER, description="Max results to return (default 5, max 20)"),
},
required=["query"],
),
),
]

View File

@@ -2,18 +2,20 @@
Reminders tools.
Reminders are stored in persona/REMINDERS.md and automatically surfaced
in the system prompt at Tier 2+. Use these tools to add, list, and clear
pending reminders.
in the system prompt at Tier 2+. Each reminder can have an optional due date —
only due or undated reminders surface in context; future-dated ones are stored
but invisible until their date arrives.
Operations:
reminders_add — append a new reminder entry
reminders_list — return all current reminders (or a message if empty)
reminders_clear — erase all reminders (moved here from cron.py for consistency;
cron.py still calls the same underlying file)
reminders_add — append a new reminder, optional due date (YYYY-MM-DD)
reminders_list — return all reminders with due status (including future)
reminders_remove — remove a single reminder by number
reminders_clear — erase all reminders
"""
import asyncio
from datetime import datetime, timezone
import re
from datetime import datetime, timezone, date as _date
from pathlib import Path
from google.genai import types
@@ -50,6 +52,46 @@ def _sections_to_text(sections: list[tuple[str, str]]) -> str:
return "".join(f"\n## {h}\n\n{b}\n" for h, b in sections)
def _parse_due(body: str) -> _date | None:
"""Extract due date from a 'due: YYYY-MM-DD' line in the body, if present."""
m = re.search(r'^due:\s*(\d{4}-\d{2}-\d{2})', body, re.MULTILINE | re.IGNORECASE)
if not m:
return None
try:
return _date.fromisoformat(m.group(1))
except ValueError:
return None
def _today() -> _date:
return datetime.now().astimezone().date()
def _is_due_or_undated(body: str) -> bool:
"""Return True if this reminder has no due date or its due date is today or past."""
due = _parse_due(body)
return due is None or due <= _today()
def _due_label(body: str) -> str:
"""Return a human-readable due status string for reminders_list output."""
due = _parse_due(body)
if due is None:
return ""
today = _today()
if due < today:
days = (today - due).days
return f" [OVERDUE by {days} day{'s' if days != 1 else ''} — due {due}]"
if due == today:
return " [due TODAY]"
return f" [due: {due}]"
def _body_without_due(body: str) -> str:
"""Strip the due: line from body for display (due status shown in heading line)."""
return re.sub(r'^due:\s*\S+\s*\n?', '', body, count=1, flags=re.MULTILINE | re.IGNORECASE).strip()
# ---------------------------------------------------------------------------
# Sync implementations
# ---------------------------------------------------------------------------
@@ -63,22 +105,29 @@ def _reminders_list() -> str:
return "No pending reminders."
lines = []
for i, (heading, body) in enumerate(sections, 1):
lines.append(f"{i}. {heading}")
if body:
# Indent body so it reads as belonging to the numbered item
for bline in body.splitlines()[:4]: # cap at 4 lines for brevity
status = _due_label(body)
lines.append(f"{i}. {heading}{status}")
display_body = _body_without_due(body)
if display_body:
for bline in display_body.splitlines()[:4]:
lines.append(f" {bline}")
lines.append("")
return "\n".join(lines).rstrip()
def _reminders_add(text: str, label: str | None = None) -> str:
def _reminders_add(text: str, label: str | None = None, due: str | None = None) -> str:
p = _reminders_path()
existing = p.read_text() if p.exists() else ""
heading = label or _now_label()
section = f"\n## {heading}\n\n{text.strip()}\n"
body = text.strip()
if due:
body = f"due: {due}\n{body}"
section = f"\n## {heading}\n\n{body}\n"
p.write_text(existing.rstrip() + "\n" + section)
return f"Reminder added: {heading}"
msg = f"Reminder added: {heading}"
if due:
msg += f" (due: {due})"
return msg
def _reminders_remove(index: int) -> str:
@@ -107,6 +156,31 @@ def _reminders_clear() -> str:
return "All reminders cleared."
# ---------------------------------------------------------------------------
# Public helper for context_loader
# ---------------------------------------------------------------------------
def load_due_reminders() -> str:
"""Return REMINDERS.md content filtered to only due and undated sections.
Called by context_loader at Tier 2+. Future-dated reminders are excluded
from the system prompt until their due date arrives.
"""
p = _reminders_path()
if not p.exists():
return ""
text = p.read_text()
if not text.strip():
return ""
sections = _parse_sections(text)
due_sections = [(h, b) for h, b in sections if _is_due_or_undated(b)]
if not due_sections:
return ""
# Strip the raw due: line from body — the date is already part of the heading context
cleaned = [(h, _body_without_due(b)) for h, b in due_sections]
return _sections_to_text(cleaned).strip()
# ---------------------------------------------------------------------------
# Async wrappers
# ---------------------------------------------------------------------------
@@ -115,8 +189,8 @@ async def reminders_list() -> str:
return await asyncio.to_thread(_reminders_list)
async def reminders_add(text: str, label: str | None = None) -> str:
return await asyncio.to_thread(_reminders_add, text, label)
async def reminders_add(text: str, label: str | None = None, due: str | None = None) -> str:
return await asyncio.to_thread(_reminders_add, text, label, due)
async def reminders_remove(index: int) -> str:
@@ -132,15 +206,17 @@ DECLARATIONS = [
name="reminders_add",
description=(
"Add a new reminder to REMINDERS.md. Reminders are automatically surfaced "
"in your context at the start of each session (Tier 2+). "
"Use this when the user asks you to remember something, follow up on something, "
"or surface a note at the next session."
"in context at the start of each session (Tier 2+). "
"Use this when the user asks you to remember something or follow up on something. "
"Set a due date to suppress the reminder until that date — useful for future tasks "
"that would be noise today."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"text": types.Schema(type=types.Type.STRING, description="The reminder text to add"),
"label": types.Schema(type=types.Type.STRING, description="Optional heading for this reminder (e.g. 'Follow up on NC Talk'). Defaults to current timestamp."),
"text": types.Schema(type=types.Type.STRING, description="The reminder text"),
"label": types.Schema(type=types.Type.STRING, description="Optional heading (e.g. 'Follow up on NC Talk'). Defaults to current timestamp."),
"due": types.Schema(type=types.Type.STRING, description="Optional due date in YYYY-MM-DD format. Reminder is hidden from context until this date arrives. Omit for an always-visible reminder."),
},
required=["text"],
),
@@ -148,9 +224,9 @@ DECLARATIONS = [
types.FunctionDeclaration(
name="reminders_list",
description=(
"Read all current pending reminders from REMINDERS.md. "
"Use this to check what reminders are queued before adding duplicates, "
"or to show the user what's pending."
"Read all pending reminders, including future-dated ones not yet in context. "
"Shows due status for each (due today, overdue, or future date). "
"Use this before adding to avoid duplicates, or to show the user what's queued."
),
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
),
@@ -158,12 +234,12 @@ DECLARATIONS = [
name="reminders_remove",
description=(
"Remove a single reminder by its number. "
"Call reminders_list first to get the numbered list, then pass the number of the reminder to remove."
"Call reminders_list first to get the numbered list, then pass the number to remove."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"index": types.Schema(type=types.Type.INTEGER, description="The number of the reminder to remove (1 = first item in reminders_list output)."),
"index": types.Schema(type=types.Type.INTEGER, description="The number of the reminder to remove (1 = first in reminders_list output)."),
},
required=["index"],
),

View File

@@ -46,27 +46,39 @@ Full API reference: [`docs/OPEN_WEBUI_API.md`](../docs/OPEN_WEBUI_API.md)
## 2. Orchestrator Tool Expansions
**Status:** Planned. Current tool count: 27. These fill obvious gaps.
**Status:** Ongoing. Current tool count: 45. Previously planned tools are all complete.
New tools for `cortex/tools/` — each follows the existing async pattern (implement function,
add `FunctionDeclaration`, register in `__init__.py`).
### Completed
All originally planned tools are live: `cortex_restart`, `cortex_logs`, `http_fetch`,
`file_list`, `file_write`, `nc_talk_send`, `email_send`, `web_push`, `agent_notes_*`.
| Tool | Module | Description |
|---|---|---|
| `cortex_restart` | `system.py` | `systemctl --user restart cortex` — Inara can apply her own config changes; returns last 10 log lines after restart |
| `cortex_logs` | `system.py` | `journalctl --user -u cortex -n N` — tail service logs for debugging |
| `http_fetch` | `web.py` | Fetch a specific URL and return content; for health checks, API probing, webhook testing — not a search, a direct GET/POST |
| `file_list` | `scratch.py` or new `files.py` | List files and directories at a path; currently only `file_read` exists |
| `file_write` | `files.py` | Write content to a file with a path allow-list (persona dir + scratch by default) |
| `nc_talk_send` | new `notify.py` | Proactively send a message to the user via Nextcloud Talk outbound API |
| `email_send` | `notify.py` | Send email via existing `email_utils.py` SMTP helper |
| `web_push` | `notify.py` | Browser push notification via Web Push API (requires push subscription stored per-user in `home/{user}/push_sub.json`; pairs with the PWA service worker) |
### Next additions
**Safety note for `cortex_restart`:** The service will drop in-flight SSE connections on restart.
Only call if no streaming response is active. Add a check or a short delay before restarting.
**Datetime note:** The current date and time is already injected into every system prompt
via `context_loader.py` (`--- System --- Current date and time: ...`). A dedicated
`datetime_now` tool is not needed — the timestamp is always in context.
**Safety note for `file_write`:** Enforce an allow-list at the tool level, not just in the prompt.
Default allow: `home/{user}/persona/{name}/` and `/tmp/cortex-scratch/`. Reject any path outside.
### Completed Round 2
| Tool | Notes |
|---|---|
| `session_search` | `tools/files.py` — full-text grep across session logs; params: `query`, `limit` (max 20); own sessions only via ContextVars. 2026-05-08 |
| `reminders due dates` | `tools/reminders.py` — optional `due: YYYY-MM-DD` on `reminders_add`; `load_due_reminders()` suppresses future-dated entries from context. 2026-05-08 |
| `spawn_agent` | `tools/agents.py` — sync sub-agent via role model; semaphore per host (`max_concurrent` in host schema); `asyncio.wait_for` timeout; admin-only. 2026-05-08 |
### Remaining Round 2
| Tool | Module | Priority | Description |
|---|---|---|---|
| `http_post` | `web.py` | Medium | POST to an external URL — for webhooks, REST APIs, form submissions. Requires a per-user host allowlist (same pattern as `email_send`) to prevent misuse. |
| `nc_talk_history` | `notify.py` | Medium | Read recent messages from a Nextcloud Talk conversation. The bot can send but cannot read — adding read capability gives it full context before replying. |
| `task_list` priority filter | `tasks.py` | Low | `task_list` accepts `status` but not `priority`. Add `priority` param so the agent can ask "what are my high-priority tasks?" without returning everything. |
| `http_fetch` max_chars | `web.py` | Low | Currently hardcapped at 8,192 chars. Accept optional `max_chars` param so callers can request more or less content. |
### Not needed / deferred
- **`datetime_now`** — already in system prompt (see note above)
- **`memory_read`** — memory files are already loaded into system prompt at Tier 2+; a tool adds no value except at Tier 1, which is a rare edge case
- **Calculator** — modern models handle arithmetic well; `shell_exec` covers edge cases for admins
- **Google Calendar** — useful but requires Google API OAuth scope expansion; defer until auth layer supports it
---

View File

@@ -55,8 +55,7 @@ automatically. Remaining work is quality/reliability parity, not ground-up desig
- `/manifest.json` and `/sw.js` served at root; added to `_PUBLIC` in auth_middleware
- Tested: install prompt confirmed working in Chromium
### [Tools] Orchestrator tool expansions
New tools for `cortex/tools/` — higher-value additions that fill obvious gaps.
### [Tools] Orchestrator tool expansions — Round 1 ✅
- [x] **`cortex_restart`** — detached subprocess, 5s delay, admin-only, confirm-required — 2026-04-29
- [x] **`cortex_logs`** — `journalctl --user -u cortex -n N`, admin-only — 2026-04-29
- [x] **`http_fetch`** — direct URL fetch via httpx, 8192 char cap — 2026-04-29
@@ -66,6 +65,40 @@ New tools for `cortex/tools/` — higher-value additions that fill obvious gaps.
- [x] **`email_send`** — SMTP via email_utils, per-user regex allowlist in `home/{user}/email_allowlist.json`, managed via Settings UI textarea + Files panel raw editor — 2026-04-29
- [x] **`web_push`** — VAPID push via pywebpush; subscriptions in `home/{user}/push_subscriptions.json`; "Enable notifications" toggle in ☰ menu; sw.js push+notificationclick handlers — 2026-05-05
### [Tools] Orchestrator tool expansions — Round 2
Next additions identified 2026-05-08. See `ARCH__FUTURE.md` §2 for design notes.
**Note:** `datetime_now` is NOT needed — current date/time is already injected into every
system prompt by `context_loader.py` at all tiers.
- [x] **`session_search`** — expose existing session search to the orchestrator — 2026-05-08
- Wraps session log grep as a tool callable in `tools/files.py`
- Params: `query: str`, `limit: int = 5` (max 20)
- Returns: excerpts with session date, newest first; own sessions only via ContextVars
- User-level (no TOOL_ROLES entry needed)
- [x] **`reminders` due-date support** — make reminders time-aware — 2026-05-08
- Optional `due: YYYY-MM-DD` on `reminders_add`; stored as `due: date` first line of body
- `context_loader.py` calls `load_due_reminders()` — future-dated sections suppressed until due
- `reminders_list` shows `[OVERDUE]`, `[due TODAY]`, or `[due: YYYY-MM-DD]` per entry
- Backward compatible — existing undated reminders always surface as before
- [x] **`spawn_agent`** — spawn a synchronous sub-agent using any role's model + tools — 2026-05-08
- `cortex/tools/agents.py``spawn_agent(task, role, tier, timeout, max_rounds)`
- Per-host asyncio semaphore keyed by `host_id` (or model type for cloud); `max_concurrent` field in host schema
- 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)
- [ ] **`task_list` priority filter** — add `priority` param alongside existing `status`
- [ ] **`http_fetch` max_chars** — optional param, default 8192, cap at 32768
### [Channel] Proactive notifications
Inara reaches out on her own initiative via NC Talk or Google Chat when a reminder
fires, a cron job completes, or something else warrants attention. The cron/reminder