feat: role-based tool access, confirmation gates, and new orchestrator tools
- auth_utils: get_user_role() reads role from auth.json (admin|user, default user) - manage_passwords: new `role` command to promote/demote users (admin-only by convention) - tools/__init__: TOOL_ROLES map, CONFIRM_REQUIRED set, get_tools_for_role(), get_openai_tools_for_role() — both orchestrators now filter tools by caller's role - tools/system: cortex_restart (detached subprocess, 5s delay), cortex_logs (admin-only) - tools/web: http_fetch — direct URL fetch, distinct from web_search - tools/files: file_list (directory listing), file_write (restricted paths, admin-only) - tools/notify: nc_talk_send — proactive outbound via notification.py - orchestrator_engine + openai_orchestrator: user_role param; CONFIRM_REQUIRED tools return a confirmation-request result instead of executing — loop breaks after Claude asks user to confirm in a follow-up message - home/scott/auth.json: role set to admin Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ calls llm_client.complete() directly, which is faster and has no orchestration o
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -24,7 +25,7 @@ from google.genai import types
|
||||
|
||||
from config import settings
|
||||
from llm_client import complete
|
||||
from tools import TOOL_DECLARATIONS, call_tool
|
||||
from tools import TOOL_DECLARATIONS, call_tool, get_tools_for_role, CONFIRM_REQUIRED
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,6 +60,7 @@ async def run(
|
||||
gemini_api_key: str | None = None,
|
||||
model_name: str | None = None,
|
||||
response_role: str = "chat",
|
||||
user_role: str = "user",
|
||||
) -> OrchestratorResult:
|
||||
"""
|
||||
Run the full orchestration loop for a task.
|
||||
@@ -89,6 +91,8 @@ async def run(
|
||||
types.Content(role="user", parts=[types.Part(text=task_with_context)])
|
||||
]
|
||||
|
||||
tool_declarations, tool_callables = get_tools_for_role(user_role)
|
||||
|
||||
tool_call_log: list[dict] = []
|
||||
gemini_summary = ""
|
||||
|
||||
@@ -101,7 +105,7 @@ async def run(
|
||||
model=model_name or settings.orchestrator_model,
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(
|
||||
tools=TOOL_DECLARATIONS,
|
||||
tools=tool_declarations,
|
||||
system_instruction=_ORCHESTRATOR_SYSTEM,
|
||||
),
|
||||
)
|
||||
@@ -127,29 +131,39 @@ async def run(
|
||||
# Add Gemini's response (with function calls) to the conversation
|
||||
contents.append(candidate.content)
|
||||
|
||||
# Execute all tool calls in parallel
|
||||
tool_tasks = [
|
||||
_execute_tool(fc.function_call.name, dict(fc.function_call.args))
|
||||
for fc in tool_call_parts
|
||||
]
|
||||
tool_results = await asyncio.gather(*tool_tasks, return_exceptions=True)
|
||||
|
||||
# Build function response parts and update log
|
||||
# Execute tool calls — check confirmation requirement before calling
|
||||
response_parts: list[types.Part] = []
|
||||
for fc_part, result in zip(tool_call_parts, tool_results):
|
||||
confirm_requested = False
|
||||
|
||||
for fc_part in tool_call_parts:
|
||||
fc = fc_part.function_call
|
||||
result_str = str(result) if not isinstance(result, Exception) else f"Error: {result}"
|
||||
logger.info("Tool %s → %d chars", fc.name, len(result_str))
|
||||
name = fc.name
|
||||
args = dict(fc.args)
|
||||
|
||||
if name in CONFIRM_REQUIRED:
|
||||
args_str = json.dumps(args, indent=2) if args else "(no arguments)"
|
||||
result_str = (
|
||||
f"⚠️ CONFIRMATION REQUIRED ⚠️\n"
|
||||
f"Tool: {name}\nArguments:\n{args_str}\n\n"
|
||||
f"Do NOT call this tool again. Tell the user exactly what you were "
|
||||
f"about to do, explain the potential impact, and ask them to confirm "
|
||||
f"by sending a follow-up message before you proceed."
|
||||
)
|
||||
confirm_requested = True
|
||||
logger.info("Tool %s blocked — confirmation required", name)
|
||||
else:
|
||||
result_str = await _execute_tool(name, args, tool_callables)
|
||||
logger.info("Tool %s → %d chars", name, len(result_str))
|
||||
|
||||
tool_call_log.append({
|
||||
"tool": fc.name,
|
||||
"args": dict(fc.args),
|
||||
"result": result_str,
|
||||
"tool": name,
|
||||
"args": args,
|
||||
"result": "[awaiting confirmation]" if name in CONFIRM_REQUIRED else result_str,
|
||||
})
|
||||
response_parts.append(
|
||||
types.Part(
|
||||
function_response=types.FunctionResponse(
|
||||
name=fc.name,
|
||||
name=name,
|
||||
response={"result": result_str},
|
||||
)
|
||||
)
|
||||
@@ -157,6 +171,28 @@ async def run(
|
||||
|
||||
contents.append(types.Content(role="user", parts=response_parts))
|
||||
|
||||
if confirm_requested:
|
||||
# Allow one more Gemini round to produce the confirmation-request message,
|
||||
# then break — tool is not executed until user confirms in a follow-up.
|
||||
conf_response = await asyncio.to_thread(
|
||||
client.models.generate_content,
|
||||
model=model_name or settings.orchestrator_model,
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(
|
||||
tools=tool_declarations,
|
||||
system_instruction=_ORCHESTRATOR_SYSTEM,
|
||||
),
|
||||
)
|
||||
conf_parts = (
|
||||
conf_response.candidates[0].content.parts
|
||||
if conf_response.candidates and conf_response.candidates[0].content
|
||||
else []
|
||||
)
|
||||
gemini_summary = "".join(
|
||||
p.text for p in conf_parts if hasattr(p, "text") and p.text
|
||||
).strip() or "This action requires your explicit confirmation before it can proceed."
|
||||
break
|
||||
|
||||
else:
|
||||
# Hit the round limit — use whatever Gemini produced last
|
||||
logger.warning("Orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds)
|
||||
@@ -192,10 +228,10 @@ async def run(
|
||||
)
|
||||
|
||||
|
||||
async def _execute_tool(name: str, args: dict) -> str:
|
||||
async def _execute_tool(name: str, args: dict, callables: dict | None = None) -> str:
|
||||
"""Execute a single tool call, catching all exceptions."""
|
||||
try:
|
||||
return await call_tool(name, args)
|
||||
return await call_tool(name, args, callables)
|
||||
except Exception as e:
|
||||
logger.warning("Tool %s failed: %s", name, e)
|
||||
return f"Tool error: {e}"
|
||||
|
||||
Reference in New Issue
Block a user