From f336ae96875c66693a477344315cf11fe5f1a270 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 12 May 2026 21:09:50 -0400 Subject: [PATCH] feat: task_list priority filter, session delete confirm, spawn_agent tool restrictions - task_list: add priority param ('low'/'normal'/'high') alongside existing status filter - Session delete: inline confirm row (Delete / Cancel) instead of immediate delete - spawn_agent: allow_tools and deny_tools per-call params; role config remains ceiling; deny_tools falls back to confirm_deny gate when no explicit tool_list is set Co-Authored-By: Claude Sonnet 4.6 --- cortex/static/app.js | 56 +++++++++++++++++++++++++++++++++-------- cortex/static/style.css | 29 +++++++++++++++++++++ cortex/tools/agents.py | 36 ++++++++++++++++++++++++++ cortex/tools/tasks.py | 13 ++++++---- 4 files changed, 118 insertions(+), 16 deletions(-) diff --git a/cortex/static/app.js b/cortex/static/app.js index 2c4b722..2f5e259 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -693,19 +693,53 @@ editBtn.onclick = enterEditMode; // ── Delete ─────────────────────────────────────────────── - delBtn.addEventListener('click', async (e) => { + delBtn.addEventListener('click', (e) => { e.stopPropagation(); - await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' }); - if (sessionId === s.session_id) { - sessionId = null; - clear_stored_session(); - currentHistory = []; - messagesEl.innerHTML = ''; - sessionEl.textContent = ''; - showToast('Session deleted'); + + // Swap row content for inline confirm + editBtn.hidden = true; + bodyEl.hidden = true; + delBtn.hidden = true; + + const confirmRow = document.createElement('div'); + confirmRow.className = 'session-confirm-row'; + confirmRow.innerHTML = + 'Delete this session?'; + + const yesBtn = document.createElement('button'); + yesBtn.className = 'session-confirm-yes'; + yesBtn.textContent = 'Delete'; + + const noBtn = document.createElement('button'); + noBtn.className = 'session-confirm-no'; + noBtn.textContent = 'Cancel'; + + confirmRow.append(yesBtn, noBtn); + item.appendChild(confirmRow); + + function cancelConfirm() { + confirmRow.remove(); + editBtn.hidden = false; + bodyEl.hidden = false; + delBtn.hidden = false; } - const res = await fetch(`/sessions?${_fileParams}`); - renderPanel((await res.json()).sessions); + + noBtn.addEventListener('click', (e) => { e.stopPropagation(); cancelConfirm(); }); + + yesBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' }); + if (sessionId === s.session_id) { + sessionId = null; + clear_stored_session(); + currentHistory = []; + messagesEl.innerHTML = ''; + sessionEl.textContent = ''; + showToast('Session deleted'); + } + const res = await fetch(`/sessions?${_fileParams}`); + renderPanel((await res.json()).sessions); + }); }); sessionsPanel.appendChild(item); diff --git a/cortex/static/style.css b/cortex/static/style.css index 1ef6779..5ac01f3 100644 --- a/cortex/static/style.css +++ b/cortex/static/style.css @@ -372,6 +372,35 @@ } .session-save-btn:hover { opacity: 0.75; } + .session-confirm-row { + display: flex; + align-items: center; + gap: 0.4rem; + flex: 1; + min-width: 0; + } + .session-confirm-label { + flex: 1; + font-size: 0.78rem; + color: #e06c75; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .session-confirm-yes, .session-confirm-no { + background: none; + border: 1px solid; + border-radius: 4px; + font-size: 0.72rem; + padding: 2px 8px; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s; + } + .session-confirm-yes { border-color: #e06c75; color: #e06c75; } + .session-confirm-no { border-color: var(--muted); color: var(--muted); } + .session-confirm-yes:hover, .session-confirm-no:hover { opacity: 0.75; } + .session-rename-input { flex: 1; min-width: 0; diff --git a/cortex/tools/agents.py b/cortex/tools/agents.py index 3154fe1..e6538c5 100644 --- a/cortex/tools/agents.py +++ b/cortex/tools/agents.py @@ -35,6 +35,8 @@ async def spawn_agent( tier: int = 1, timeout: int = 120, max_rounds: int | None = None, + allow_tools: list[str] | None = None, + deny_tools: list[str] | None = None, ) -> str: """ Spawn a sub-agent to complete a task synchronously. @@ -91,6 +93,21 @@ async def spawn_agent( confirm_allow = set(policy.get("allow", [])) confirm_deny = set(policy.get("deny", [])) + # Per-call tool restrictions — role config remains the authoritative ceiling + if allow_tools is not None: + if tool_list is not None: + tool_list = [t for t in tool_list if t in allow_tools] + else: + tool_list = list(allow_tools) + + if deny_tools is not None: + deny_set = set(deny_tools) + if tool_list is not None: + tool_list = [t for t in tool_list if t not in deny_set] + else: + # tool_list is unrestricted — block via confirm_deny so the gate fires + confirm_deny = confirm_deny | deny_set + if max_rounds is not None: model_cfg = dict(model_cfg) model_cfg["max_rounds"] = max_rounds @@ -198,6 +215,25 @@ DECLARATIONS = [ type=types.Type.INTEGER, description="Override max tool-loop iterations for this call.", ), + "allow_tools": types.Schema( + type=types.Type.ARRAY, + items=types.Schema(type=types.Type.STRING), + description=( + "Restrict the sub-agent to only these tools. " + "Intersected with the role's tool set — cannot grant more than the role allows. " + "Omit to give the sub-agent the role's full tool set. " + "Example: ['web_search', 'web_read'] for a pure research agent." + ), + ), + "deny_tools": types.Schema( + type=types.Type.ARRAY, + items=types.Schema(type=types.Type.STRING), + description=( + "Block these tools from the sub-agent regardless of role config. " + "Use to prevent destructive operations in sensitive sub-tasks. " + "Example: ['shell_exec', 'file_write', 'cortex_restart']." + ), + ), }, required=["task"], ), diff --git a/cortex/tools/tasks.py b/cortex/tools/tasks.py index b82742b..f9404ec 100644 --- a/cortex/tools/tasks.py +++ b/cortex/tools/tasks.py @@ -60,13 +60,15 @@ def _format_task(t: dict) -> str: # Sync implementations — called via asyncio.to_thread # --------------------------------------------------------------------------- -def _task_list(status: str | None) -> str: +def _task_list(status: str | None, priority: str | None) -> str: tasks = _load() if status: tasks = [t for t in tasks if t["status"] == status] + if priority: + tasks = [t for t in tasks if t.get("priority") == priority] if not tasks: - label = f"No {status} tasks." if status else "No tasks yet." - return label + filters = " ".join(f for f in [status, priority] if f) + return f"No {filters} tasks." if filters else "No tasks yet." lines = [f"Tasks ({len(tasks)}):\n"] for t in tasks: lines.append(_format_task(t)) @@ -118,8 +120,8 @@ def _task_complete(task_id: str) -> str: # Async wrappers # --------------------------------------------------------------------------- -async def task_list(status: str | None = None) -> str: - return await asyncio.to_thread(_task_list, status) +async def task_list(status: str | None = None, priority: str | None = None) -> str: + return await asyncio.to_thread(_task_list, status, priority) async def task_create(title: str, description: str | None = None, @@ -148,6 +150,7 @@ DECLARATIONS = [ type=types.Type.OBJECT, properties={ "status": types.Schema(type=types.Type.STRING, description="Filter by status: 'todo', 'in_progress', or 'done'. Omit to list all."), + "priority": types.Schema(type=types.Type.STRING, description="Filter by priority: 'low', 'normal', or 'high'. Omit to list all priorities."), }, ), ),