Compare commits
5 Commits
f8f7cd75da
...
47d23a7b2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47d23a7b2f | ||
|
|
09d775b47b | ||
|
|
6ad7597db8 | ||
|
|
8e512d4e11 | ||
|
|
750cde489d |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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 & 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})
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` (1–4, default 1), `timeout` seconds, `max_rounds` override. Only works with `local_openai` and `gemini_api` models. |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
205
cortex/tools/agents.py
Normal 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"],
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -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"],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user