feat: proper confirmation-resume flow + per-user tool policy

Fixes the broken confirmation gate where users had no way to approve
or deny a blocked tool call in the web UI.

Changes:
- orchestrator_engine.py: add OrchestrateCheckpoint dataclass, extract
  loop into _run_from_contents(), add resume() function
- openai_orchestrator.py: same treatment — _run_from_messages(), resume()
- routers/orchestrator.py: POST /{job_id}/confirm and /deny endpoints,
  separate _checkpoints store, _resume_job() + _finalize_job() helpers,
  "awaiting_confirmation" job status with pending_confirmation payload
- auth_utils.py: get_tool_policy() and save_tool_policy() helpers reading
  home/{user}/tool_policy.json (allow/deny lists)
- routers/orchestrator.py: loads tool_policy per user and passes
  confirm_allow/confirm_deny to both engines
- app.js: poll loop handles awaiting_confirmation — shows Confirm/Deny
  buttons inline, resumes polling after user action
- settings.html + settings.py: Tool Permissions section with allow/deny
  textareas, POST /settings/tool-policy route
- style.css: .confirm-gate, .confirm-btn, .deny-btn styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-30 19:14:53 -04:00
parent bce7de647c
commit 6405dd338d
8 changed files with 733 additions and 159 deletions

View File

@@ -224,3 +224,22 @@ def get_user_channels(username: str) -> dict:
return json.loads(path.read_text()) return json.loads(path.read_text())
except Exception: except Exception:
return {} return {}
def get_tool_policy(username: str) -> dict:
"""Return the parsed tool_policy.json for a user.
Keys:
allow — tools in CONFIRM_REQUIRED that this user has pre-approved (skip gate)
deny — tools always blocked for this user regardless of global CONFIRM_REQUIRED
"""
path = settings.home_root() / username / "tool_policy.json"
try:
return json.loads(path.read_text())
except Exception:
return {}
def save_tool_policy(username: str, data: dict) -> None:
path = settings.home_root() / username / "tool_policy.json"
path.write_text(json.dumps(data, indent=2) + "\n")

View File

@@ -24,8 +24,8 @@ import logging
from openai import AsyncOpenAI from openai import AsyncOpenAI
from config import settings from config import settings
from orchestrator_engine import OrchestratorResult from orchestrator_engine import OrchestrateCheckpoint, OrchestratorResult
from tools import OPENAI_TOOL_SCHEMAS, call_tool, get_openai_tools_for_role, CONFIRM_REQUIRED from tools import OPENAI_TOOL_SCHEMAS, call_tool, get_openai_tools_for_role, get_tools_for_role, CONFIRM_REQUIRED
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,6 +45,8 @@ async def run(
model_cfg: dict | None = None, model_cfg: dict | None = None,
respond_with_final: bool = True, respond_with_final: bool = True,
user_role: str = "user", user_role: str = "user",
confirm_allow: set[str] | None = None,
confirm_deny: set[str] | None = None,
) -> OrchestratorResult: ) -> OrchestratorResult:
""" """
Run a tool-enabled task using an OpenAI-compatible API. Run a tool-enabled task using an OpenAI-compatible API.
@@ -56,36 +58,22 @@ async def run(
model_cfg: Resolved model config from model_registry (local_openai type) model_cfg: Resolved model config from model_registry (local_openai type)
respond_with_final: If False, return just the tool-loop summary without a respond_with_final: If False, return just the tool-loop summary without a
full persona-voiced response (faster; for cron/background) full persona-voiced response (faster; for cron/background)
confirm_allow: Tools to bypass the confirmation gate for this user
confirm_deny: Tools to always block for this user
Returns: Returns:
OrchestratorResult — same shape as the Gemini engine for drop-in compatibility OrchestratorResult — if checkpoint is set, the job is awaiting confirmation
""" """
if not model_cfg: if not model_cfg:
raise RuntimeError("model_cfg is required for the OpenAI orchestrator") raise RuntimeError("model_cfg is required for the OpenAI orchestrator")
api_url = model_cfg.get("api_url", "") _confirm_allow = frozenset(confirm_allow or ())
api_key = model_cfg.get("api_key", "") or "none" _confirm_deny = frozenset(confirm_deny or ())
model_name = model_cfg.get("model_name", "") effective_confirm = (CONFIRM_REQUIRED - set(_confirm_allow)) | set(_confirm_deny)
host_type = model_cfg.get("host_type", "openwebui")
if not api_url or not model_name: client, model_name, active_tools = _build_client(model_cfg)
raise RuntimeError(
f"model_cfg missing api_url or model_name: {model_cfg.get('label', model_cfg)}"
)
# Open WebUI's OpenAI-compatible endpoint lives at /api/chat/completions,
# so the SDK base_url needs the /api prefix; standard OpenAI-layout hosts don't.
base_url = api_url.rstrip("/")
if host_type == "openwebui":
base_url = base_url + "/api"
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
# System prompt: persona context + brief tool instruction
sys_content = (system_prompt or "") + _TOOL_INSTRUCTION sys_content = (system_prompt or "") + _TOOL_INSTRUCTION
# Build messages: [system, ...recent_session, current_task]
# Strip non-standard metadata fields (backend, host, etc.) before sending.
messages: list[dict] = [{"role": "system", "content": sys_content}] messages: list[dict] = [{"role": "system", "content": sys_content}]
if session_messages: if session_messages:
messages.extend( messages.extend(
@@ -94,13 +82,132 @@ async def run(
) )
messages.append({"role": "user", "content": task}) messages.append({"role": "user", "content": task})
active_tools = get_openai_tools_for_role(user_role)
active_callables: dict | None = None # resolved lazily below
tool_call_log: list[dict] = [] tool_call_log: list[dict] = []
final_response, checkpoint = await _run_from_messages(
client=client,
messages=messages,
active_tools=active_tools,
tool_call_log=tool_call_log,
effective_confirm=effective_confirm,
model_name=model_name,
task=task,
model_cfg=model_cfg,
respond_with_final=respond_with_final,
user_role=user_role,
confirm_allow=_confirm_allow,
confirm_deny=_confirm_deny,
starting_round=0,
)
if checkpoint:
return OrchestratorResult(
response=final_response,
tool_calls=list(tool_call_log),
backend="local",
gemini_summary=final_response,
checkpoint=checkpoint,
)
model_label = model_cfg.get("label") or model_name
logger.info("OpenAI orchestrator complete — model=%s tools=%d", model_label, len(tool_call_log))
return OrchestratorResult(
response=final_response,
tool_calls=tool_call_log,
backend="local",
gemini_summary=final_response,
)
async def resume(checkpoint: OrchestrateCheckpoint, confirmed: bool) -> OrchestratorResult:
"""Continue an OpenAI orchestrator job that was paused at a confirmation gate."""
client, model_name, active_tools = _build_client(checkpoint.model_cfg)
effective_confirm = (CONFIRM_REQUIRED - set(checkpoint.confirm_allow)) | set(checkpoint.confirm_deny)
messages = list(checkpoint.pre_fn_state)
tool_call_log = [t for t in checkpoint.tool_call_log if t["result"] != "[awaiting confirmation]"]
# Build tool responses for this round
for er in checkpoint.executed_results:
messages.append({
"role": "tool",
"tool_call_id": er.get("tool_call_id", er["name"]),
"content": er["result"],
})
for pt in checkpoint.pending_tools:
if confirmed:
_, callables = get_tools_for_role(checkpoint.user_role)
result_str = await _execute_tool_dict(pt["name"], pt["args"], checkpoint.user_role)
logger.info("Confirmed tool %s%d chars", pt["name"], len(result_str))
else:
result_str = "Action denied by user."
logger.info("Tool %s denied by user", pt["name"])
tool_call_log.append({"tool": pt["name"], "args": pt["args"], "result": result_str})
messages.append({
"role": "tool",
"tool_call_id": pt.get("tool_call_id", pt["name"]),
"content": result_str,
})
final_response, new_checkpoint = await _run_from_messages(
client=client,
messages=messages,
active_tools=active_tools,
tool_call_log=tool_call_log,
effective_confirm=effective_confirm,
model_name=model_name,
task=checkpoint.task,
model_cfg=checkpoint.model_cfg,
respond_with_final=checkpoint.respond_with_final,
user_role=checkpoint.user_role,
confirm_allow=checkpoint.confirm_allow,
confirm_deny=checkpoint.confirm_deny,
starting_round=checkpoint.rounds_used,
)
if new_checkpoint:
return OrchestratorResult(
response=final_response,
tool_calls=list(tool_call_log),
backend="local",
gemini_summary=final_response,
checkpoint=new_checkpoint,
)
model_label = (checkpoint.model_cfg or {}).get("label") or model_name
logger.info("OpenAI orchestrator resumed — model=%s tools=%d", model_label, len(tool_call_log))
return OrchestratorResult(
response=final_response,
tool_calls=tool_call_log,
backend="local",
gemini_summary=final_response,
)
async def _run_from_messages(
client,
messages: list[dict],
active_tools: list,
tool_call_log: list[dict],
effective_confirm: set[str],
model_name: str,
task: str,
model_cfg: dict | None,
respond_with_final: bool,
user_role: str,
confirm_allow: frozenset,
confirm_deny: frozenset,
starting_round: int = 0,
) -> tuple[str, OrchestrateCheckpoint | None]:
"""
Run the OpenAI ReAct loop from the current messages state.
Returns (final_response, checkpoint) — checkpoint is set if confirmation is needed.
"""
final_response = "" final_response = ""
for round_num in range(settings.orchestrator_max_rounds): for round_num in range(starting_round, settings.orchestrator_max_rounds):
logger.info("OpenAI orchestrator round %d / %d model=%s", logger.info("OpenAI orchestrator round %d / %d model=%s",
round_num + 1, settings.orchestrator_max_rounds, model_name) round_num + 1, settings.orchestrator_max_rounds, model_name)
@@ -112,29 +219,28 @@ async def run(
) )
choice = response.choices[0] choice = response.choices[0]
msg = choice.message msg = choice.message
# Append the assistant turn (MUST include tool_calls if present so the
# next request is valid — OpenAI requires the full history to be consistent)
assistant_msg: dict = {"role": "assistant"} assistant_msg: dict = {"role": "assistant"}
if msg.content: if msg.content:
assistant_msg["content"] = msg.content assistant_msg["content"] = msg.content
if msg.tool_calls: if msg.tool_calls:
assistant_msg["tool_calls"] = [ assistant_msg["tool_calls"] = [
{ {
"id": tc.id, "id": tc.id,
"type": "function", "type": "function",
"function": { "function": {"name": tc.function.name, "arguments": tc.function.arguments},
"name": tc.function.name,
"arguments": tc.function.arguments,
},
} }
for tc in msg.tool_calls for tc in msg.tool_calls
] ]
messages.append(assistant_msg) messages.append(assistant_msg)
if choice.finish_reason == "tool_calls" and msg.tool_calls: if choice.finish_reason == "tool_calls" and msg.tool_calls:
confirm_requested = False # Snapshot state before tool responses for potential checkpoint
pre_fn_state = list(messages)
pending_tools: list[dict] = []
executed_results: list[dict] = []
for tc in msg.tool_calls: for tc in msg.tool_calls:
name = tc.function.name name = tc.function.name
@@ -143,34 +249,23 @@ async def run(
except json.JSONDecodeError: except json.JSONDecodeError:
args_parsed = {"raw": tc.function.arguments} args_parsed = {"raw": tc.function.arguments}
if name in CONFIRM_REQUIRED: if name in effective_confirm:
args_str = json.dumps(args_parsed, indent=2) if args_parsed else "(no arguments)" pending_tools.append({"name": name, "args": args_parsed, "tool_call_id": tc.id})
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) logger.info("Tool %s blocked — confirmation required", name)
else: else:
result_str = await _execute_tool(name, tc.function.arguments, user_role) result_str = await _execute_tool(name, tc.function.arguments, user_role)
logger.info("Tool %s%d chars", name, len(result_str)) logger.info("Tool %s%d chars", name, len(result_str))
executed_results.append({"name": name, "args": args_parsed, "result": result_str, "tool_call_id": tc.id})
tool_call_log.append({"tool": name, "args": args_parsed, "result": result_str})
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result_str})
tool_call_log.append({ if pending_tools:
"tool": name, # Add placeholder responses
"args": args_parsed, for pt in pending_tools:
"result": "[awaiting confirmation]" if name in CONFIRM_REQUIRED else result_str, placeholder = f"[AWAITING USER CONFIRMATION for {pt['name']}]"
}) tool_call_log.append({"tool": pt["name"], "args": pt["args"], "result": "[awaiting confirmation]"})
messages.append({ messages.append({"role": "tool", "tool_call_id": pt["tool_call_id"], "content": placeholder})
"role": "tool",
"tool_call_id": tc.id,
"content": result_str,
})
if confirm_requested:
# One more model round to produce the confirmation-request message, then stop.
conf_resp = await client.chat.completions.create( conf_resp = await client.chat.completions.create(
model=model_name, model=model_name,
messages=messages, messages=messages,
@@ -180,10 +275,24 @@ async def run(
final_response = conf_resp.choices[0].message.content or ( final_response = conf_resp.choices[0].message.content or (
"This action requires your explicit confirmation before it can proceed." "This action requires your explicit confirmation before it can proceed."
) )
break
checkpoint = OrchestrateCheckpoint(
engine="openai",
pre_fn_state=pre_fn_state,
executed_results=executed_results,
pending_tools=pending_tools,
tool_call_log=list(tool_call_log),
task=task,
model_cfg=model_cfg,
respond_with_final=respond_with_final,
user_role=user_role,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
rounds_used=round_num + 2,
)
return final_response, checkpoint
else: else:
# finish_reason == "stop" (or no tool_calls) — model is done
final_response = msg.content or "" final_response = msg.content or ""
logger.info( logger.info(
"OpenAI orchestrator done after %d round(s). Tools used: %d", "OpenAI orchestrator done after %d round(s). Tools used: %d",
@@ -192,30 +301,37 @@ async def run(
break break
else: else:
# Hit the round limit
logger.warning("OpenAI orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds) logger.warning("OpenAI orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds)
final_response = ( final_response = (
f"Reached the tool iteration limit ({settings.orchestrator_max_rounds} rounds). " f"Reached the tool iteration limit ({settings.orchestrator_max_rounds} rounds). "
"Here is what was gathered:\n\n" "Here is what was gathered:\n\n"
+ "\n\n".join( + "\n\n".join(f"**{t['tool']}**: {t['result'][:500]}" for t in tool_call_log)
f"**{t['tool']}**: {t['result'][:500]}" for t in tool_call_log
)
) )
model_label = model_cfg.get("label") or model_name return final_response, None
logger.info("OpenAI orchestrator complete — model=%s tools=%d", model_label, len(tool_call_log))
return OrchestratorResult(
response=final_response, def _build_client(model_cfg: dict | None) -> tuple:
tool_calls=tool_call_log, """Build AsyncOpenAI client and return (client, model_name, active_tools)."""
backend="local", if not model_cfg:
gemini_summary=final_response, # reused for UI display; same content in single-model mode raise RuntimeError("model_cfg is required for the OpenAI orchestrator")
) api_url = model_cfg.get("api_url", "")
api_key = model_cfg.get("api_key", "") or "none"
model_name = model_cfg.get("model_name", "")
host_type = model_cfg.get("host_type", "openwebui")
if not api_url or not model_name:
raise RuntimeError(
f"model_cfg missing api_url or model_name: {model_cfg.get('label', model_cfg)}"
)
base_url = api_url.rstrip("/")
if host_type == "openwebui":
base_url = base_url + "/api"
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
return client, model_name, OPENAI_TOOL_SCHEMAS
async def _execute_tool(name: str, arguments_json: str, user_role: str = "user") -> str: async def _execute_tool(name: str, arguments_json: str, user_role: str = "user") -> str:
"""Parse tool arguments and execute with role-filtered callables.""" """Parse tool arguments and execute with role-filtered callables."""
from tools import get_tools_for_role
_, callables = get_tools_for_role(user_role) _, callables = get_tools_for_role(user_role)
try: try:
args = json.loads(arguments_json) args = json.loads(arguments_json)
@@ -226,3 +342,13 @@ async def _execute_tool(name: str, arguments_json: str, user_role: str = "user")
except Exception as e: except Exception as e:
logger.warning("Tool %s failed: %s", name, e) logger.warning("Tool %s failed: %s", name, e)
return f"Tool error: {e}" return f"Tool error: {e}"
async def _execute_tool_dict(name: str, args: dict, user_role: str = "user") -> str:
"""Execute a tool from a pre-parsed args dict."""
_, callables = get_tools_for_role(user_role)
try:
return await call_tool(name, args, callables)
except Exception as e:
logger.warning("Tool %s failed: %s", name, e)
return f"Tool error: {e}"

View File

@@ -44,12 +44,39 @@ Keep your summary factual and complete. Include relevant URLs, data, and specifi
If no tools are needed, return an empty summary.""" If no tools are needed, return an empty summary."""
@dataclass
class OrchestrateCheckpoint:
"""Saved execution state for a job paused at a confirmation gate."""
engine: str # "gemini" | "openai"
pre_fn_state: list # conversation state before function responses
executed_results: list[dict] # tools that already ran this round
pending_tools: list[dict] # [{name, args}] awaiting confirmation
tool_call_log: list[dict] # all tool calls so far
task: str
# Gemini-specific config (unused by openai engine)
system_prompt: str = ""
session_messages: list | None = None
model_name: str | None = None
gemini_api_key: str | None = None
respond_with_claude: bool = True
response_role: str = "chat"
# OpenAI-specific config (unused by gemini engine)
model_cfg: dict | None = None
respond_with_final: bool = True
# Common
user_role: str = "user"
confirm_allow: frozenset = field(default_factory=frozenset)
confirm_deny: frozenset = field(default_factory=frozenset)
rounds_used: int = 0
@dataclass @dataclass
class OrchestratorResult: class OrchestratorResult:
response: str # final user-facing response (from Claude) response: str # final user-facing response (from Claude)
tool_calls: list[dict] = field(default_factory=list) # [{tool, args, result}] tool_calls: list[dict] = field(default_factory=list) # [{tool, args, result}]
backend: str = "claude" # model that produced the final response backend: str = "claude" # model that produced the final response
gemini_summary: str = "" # what Gemini handed to Claude (debug/display) gemini_summary: str = "" # what Gemini handed to Claude (debug/display)
checkpoint: OrchestrateCheckpoint | None = None # set when awaiting confirmation
async def run( async def run(
@@ -61,6 +88,8 @@ async def run(
model_name: str | None = None, model_name: str | None = None,
response_role: str = "chat", response_role: str = "chat",
user_role: str = "user", user_role: str = "user",
confirm_allow: set[str] | None = None,
confirm_deny: set[str] | None = None,
) -> OrchestratorResult: ) -> OrchestratorResult:
""" """
Run the full orchestration loop for a task. Run the full orchestration loop for a task.
@@ -72,9 +101,11 @@ async def run(
respond_with_claude: If False, return Gemini's summary as the response (useful for respond_with_claude: If False, return Gemini's summary as the response (useful for
background/cron tasks where a polished reply isn't needed) background/cron tasks where a polished reply isn't needed)
gemini_api_key: Per-user Gemini API key (falls back to GEMINI_API_KEY in .env) gemini_api_key: Per-user Gemini API key (falls back to GEMINI_API_KEY in .env)
confirm_allow: Tools to bypass the confirmation gate for this user
confirm_deny: Tools to always block for this user
Returns: Returns:
OrchestratorResult with response, tool call log, backend used, and Gemini summary OrchestratorResult — if checkpoint is set, the job is awaiting confirmation
""" """
api_key = gemini_api_key or settings.gemini_api_key api_key = gemini_api_key or settings.gemini_api_key
if not api_key: if not api_key:
@@ -85,19 +116,157 @@ async def run(
client = genai.Client(api_key=api_key) client = genai.Client(api_key=api_key)
# Seed Gemini with the task — include recent session context if available _confirm_allow = frozenset(confirm_allow or ())
_confirm_deny = frozenset(confirm_deny or ())
effective_confirm = (CONFIRM_REQUIRED - set(_confirm_allow)) | set(_confirm_deny)
task_with_context = _build_task_prompt(task, session_messages) task_with_context = _build_task_prompt(task, session_messages)
contents: list[types.Content] = [ contents: list[types.Content] = [
types.Content(role="user", parts=[types.Part(text=task_with_context)]) types.Content(role="user", parts=[types.Part(text=task_with_context)])
] ]
tool_declarations, tool_callables = get_tools_for_role(user_role) tool_declarations, tool_callables = get_tools_for_role(user_role)
tool_call_log: list[dict] = [] tool_call_log: list[dict] = []
gemini_summary, checkpoint = await _run_from_contents(
client=client,
contents=contents,
tool_declarations=tool_declarations,
tool_callables=tool_callables,
tool_call_log=tool_call_log,
effective_confirm=effective_confirm,
model_name=model_name,
task=task,
system_prompt=system_prompt,
session_messages=session_messages,
respond_with_claude=respond_with_claude,
response_role=response_role,
user_role=user_role,
confirm_allow=_confirm_allow,
confirm_deny=_confirm_deny,
starting_round=0,
gemini_api_key=api_key,
)
if checkpoint:
return OrchestratorResult(
response=gemini_summary,
tool_calls=list(tool_call_log),
backend="gemini",
gemini_summary=gemini_summary,
checkpoint=checkpoint,
)
return await _claude_handoff(
task=task,
tool_call_log=tool_call_log,
gemini_summary=gemini_summary,
system_prompt=system_prompt,
session_messages=session_messages,
respond_with_claude=respond_with_claude,
response_role=response_role,
)
async def resume(checkpoint: OrchestrateCheckpoint, confirmed: bool) -> OrchestratorResult:
"""Continue a job that was paused at a confirmation gate."""
api_key = checkpoint.gemini_api_key or settings.gemini_api_key
client = genai.Client(api_key=api_key)
tool_declarations, tool_callables = get_tools_for_role(checkpoint.user_role)
effective_confirm = (CONFIRM_REQUIRED - set(checkpoint.confirm_allow)) | set(checkpoint.confirm_deny)
# Rebuild from saved state — strip "[awaiting confirmation]" placeholders
contents = list(checkpoint.pre_fn_state)
tool_call_log = [t for t in checkpoint.tool_call_log if t["result"] != "[awaiting confirmation]"]
# Build function responses for this round
response_parts: list[types.Part] = []
for er in checkpoint.executed_results:
response_parts.append(types.Part(function_response=types.FunctionResponse(
name=er["name"], response={"result": er["result"]}
)))
for pt in checkpoint.pending_tools:
if confirmed:
result_str = await _execute_tool(pt["name"], pt["args"], tool_callables)
logger.info("Confirmed tool %s%d chars", pt["name"], len(result_str))
else:
result_str = "Action denied by user."
logger.info("Tool %s denied by user", pt["name"])
tool_call_log.append({"tool": pt["name"], "args": pt["args"], "result": result_str})
response_parts.append(types.Part(function_response=types.FunctionResponse(
name=pt["name"], response={"result": result_str}
)))
contents.append(types.Content(role="user", parts=response_parts))
gemini_summary, new_checkpoint = await _run_from_contents(
client=client,
contents=contents,
tool_declarations=tool_declarations,
tool_callables=tool_callables,
tool_call_log=tool_call_log,
effective_confirm=effective_confirm,
model_name=checkpoint.model_name,
task=checkpoint.task,
system_prompt=checkpoint.system_prompt,
session_messages=checkpoint.session_messages,
respond_with_claude=checkpoint.respond_with_claude,
response_role=checkpoint.response_role,
user_role=checkpoint.user_role,
confirm_allow=checkpoint.confirm_allow,
confirm_deny=checkpoint.confirm_deny,
starting_round=checkpoint.rounds_used,
gemini_api_key=api_key,
)
if new_checkpoint:
return OrchestratorResult(
response=gemini_summary,
tool_calls=list(tool_call_log),
backend="gemini",
gemini_summary=gemini_summary,
checkpoint=new_checkpoint,
)
return await _claude_handoff(
task=checkpoint.task,
tool_call_log=tool_call_log,
gemini_summary=gemini_summary,
system_prompt=checkpoint.system_prompt,
session_messages=checkpoint.session_messages,
respond_with_claude=checkpoint.respond_with_claude,
response_role=checkpoint.response_role,
)
async def _run_from_contents(
client,
contents: list,
tool_declarations: list,
tool_callables: dict,
tool_call_log: list[dict],
effective_confirm: set[str],
model_name: str | None,
task: str,
system_prompt: str,
session_messages: list[dict] | None,
respond_with_claude: bool,
response_role: str,
user_role: str,
confirm_allow: frozenset,
confirm_deny: frozenset,
starting_round: int = 0,
gemini_api_key: str | 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 = "" gemini_summary = ""
# --- ReAct tool loop --- for round_num in range(starting_round, settings.orchestrator_max_rounds):
for round_num in range(settings.orchestrator_max_rounds):
logger.info("Orchestrator round %d for task: %.80s", round_num + 1, task) logger.info("Orchestrator round %d for task: %.80s", round_num + 1, task)
response = await asyncio.to_thread( response = await asyncio.to_thread(
@@ -113,67 +282,56 @@ async def run(
candidate = response.candidates[0] candidate = response.candidates[0]
parts = candidate.content.parts if candidate.content else [] parts = candidate.content.parts if candidate.content else []
# Check if Gemini wants to call any tools
tool_call_parts = [ tool_call_parts = [
p for p in parts p for p in parts
if hasattr(p, "function_call") and p.function_call and p.function_call.name if hasattr(p, "function_call") and p.function_call and p.function_call.name
] ]
if not tool_call_parts: if not tool_call_parts:
# No more tool calls — extract Gemini's text summary
gemini_summary = "".join( gemini_summary = "".join(
p.text for p in parts if hasattr(p, "text") and p.text p.text for p in parts if hasattr(p, "text") and p.text
).strip() ).strip()
logger.info("Orchestrator done after %d round(s). Tools used: %d", logger.info("Orchestrator done after %d round(s). Tools used: %d",
round_num + 1, len(tool_call_log)) round_num + 1, len(tool_call_log))
break return gemini_summary, None
# Add Gemini's response (with function calls) to the conversation
contents.append(candidate.content) contents.append(candidate.content)
# Execute tool calls — check confirmation requirement before calling # Snapshot state before function responses — used if a checkpoint is needed
pre_fn_state = list(contents)
response_parts: list[types.Part] = [] response_parts: list[types.Part] = []
confirm_requested = False pending_tools: list[dict] = []
executed_results: list[dict] = []
for fc_part in tool_call_parts: for fc_part in tool_call_parts:
fc = fc_part.function_call fc = fc_part.function_call
name = fc.name name = fc.name
args = dict(fc.args) args = dict(fc.args)
if name in CONFIRM_REQUIRED: if name in effective_confirm:
args_str = json.dumps(args, indent=2) if args else "(no arguments)" pending_tools.append({"name": name, "args": args})
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) logger.info("Tool %s blocked — confirmation required", name)
else: else:
result_str = await _execute_tool(name, args, tool_callables) result_str = await _execute_tool(name, args, tool_callables)
logger.info("Tool %s%d chars", name, len(result_str)) logger.info("Tool %s%d chars", name, len(result_str))
executed_results.append({"name": name, "args": args, "result": result_str})
tool_call_log.append({"tool": name, "args": args, "result": result_str})
response_parts.append(types.Part(function_response=types.FunctionResponse(
name=name, response={"result": result_str}
)))
tool_call_log.append({ if pending_tools:
"tool": name, # Add placeholder responses and get Gemini to produce the confirmation message
"args": args, for pt in pending_tools:
"result": "[awaiting confirmation]" if name in CONFIRM_REQUIRED else result_str, placeholder = f"[AWAITING USER CONFIRMATION for {pt['name']}]"
}) response_parts.append(types.Part(function_response=types.FunctionResponse(
response_parts.append( name=pt["name"], response={"result": placeholder}
types.Part( )))
function_response=types.FunctionResponse( tool_call_log.append({"tool": pt["name"], "args": pt["args"], "result": "[awaiting confirmation]"})
name=name,
response={"result": result_str},
)
)
)
contents.append(types.Content(role="user", parts=response_parts)) 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( conf_response = await asyncio.to_thread(
client.models.generate_content, client.models.generate_content,
model=model_name or settings.orchestrator_model, model=model_name or settings.orchestrator_model,
@@ -191,10 +349,30 @@ async def run(
gemini_summary = "".join( gemini_summary = "".join(
p.text for p in conf_parts if hasattr(p, "text") and p.text 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." ).strip() or "This action requires your explicit confirmation before it can proceed."
break
checkpoint = OrchestrateCheckpoint(
engine="gemini",
pre_fn_state=pre_fn_state,
executed_results=executed_results,
pending_tools=pending_tools,
tool_call_log=list(tool_call_log),
task=task,
system_prompt=system_prompt,
session_messages=session_messages,
model_name=model_name,
gemini_api_key=gemini_api_key,
respond_with_claude=respond_with_claude,
response_role=response_role,
user_role=user_role,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
rounds_used=round_num + 2,
)
return gemini_summary, checkpoint
contents.append(types.Content(role="user", parts=response_parts))
else: else:
# Hit the round limit — use whatever Gemini produced last
logger.warning("Orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds) logger.warning("Orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds)
gemini_summary = ( gemini_summary = (
f"Reached the tool iteration limit ({settings.orchestrator_max_rounds} rounds). " f"Reached the tool iteration limit ({settings.orchestrator_max_rounds} rounds). "
@@ -202,21 +380,28 @@ async def run(
+ "\n\n".join(f"**{t['tool']}**: {t['result'][:500]}" for t in tool_call_log) + "\n\n".join(f"**{t['tool']}**: {t['result'][:500]}" for t in tool_call_log)
) )
# --- Claude handoff --- return gemini_summary, None
async def _claude_handoff(
task: str,
tool_call_log: list[dict],
gemini_summary: str,
system_prompt: str,
session_messages: list[dict] | None,
respond_with_claude: bool,
response_role: str,
) -> OrchestratorResult:
if respond_with_claude: if respond_with_claude:
claude_prompt = _build_claude_prompt(task, tool_call_log, gemini_summary) claude_prompt = _build_claude_prompt(task, tool_call_log, gemini_summary)
# Merge with session history so Claude has conversation context
messages = list(session_messages or []) messages = list(session_messages or [])
messages.append({"role": "user", "content": claude_prompt}) messages.append({"role": "user", "content": claude_prompt})
response_text, backend = await complete( response_text, backend = await complete(
system_prompt=system_prompt, system_prompt=system_prompt,
messages=messages, messages=messages,
role=response_role, role=response_role,
) )
else: else:
# Cron/background tasks: return Gemini's summary directly, no Claude call
response_text = gemini_summary or "No information gathered." response_text = gemini_summary or "No information gathered."
backend = "gemini" backend = "gemini"
@@ -242,12 +427,11 @@ def _build_task_prompt(task: str, session_messages: list[dict] | None) -> str:
if not session_messages: if not session_messages:
return task return task
# Include last few turns for context (don't send the full history to keep tokens low) recent = session_messages[-6:]
recent = session_messages[-6:] # last 3 turns
history_lines = [] history_lines = []
for msg in recent: for msg in recent:
label = "User" if msg["role"] == "user" else "Assistant" label = "User" if msg["role"] == "user" else "Assistant"
history_lines.append(f"{label}: {msg['content'][:300]}") # truncate long messages history_lines.append(f"{label}: {msg['content'][:300]}")
context = "\n".join(history_lines) context = "\n".join(history_lines)
return f"<recent_conversation>\n{context}\n</recent_conversation>\n\nCurrent request: {task}" return f"<recent_conversation>\n{context}\n</recent_conversation>\n\nCurrent request: {task}"
@@ -265,7 +449,6 @@ def _build_claude_prompt(
parts.append("## Research gathered\n") parts.append("## Research gathered\n")
for tc in tool_calls: for tc in tool_calls:
parts.append(f"### {tc['tool']}({_format_args(tc['args'])})") parts.append(f"### {tc['tool']}({_format_args(tc['args'])})")
# Truncate very long results — Claude gets the gist
result = tc["result"] result = tc["result"]
if len(result) > 2000: if len(result) > 2000:
result = result[:2000] + "\n… [truncated]" result = result[:2000] + "\n… [truncated]"

View File

@@ -15,10 +15,10 @@ import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from auth_utils import get_user_gemini_key, get_user_role from auth_utils import get_user_gemini_key, get_user_role, get_tool_policy
from config import settings from config import settings
from context_loader import load_context from context_loader import load_context
from persona import set_context, validate as validate_persona from persona import set_context, validate as validate_persona
@@ -31,12 +31,16 @@ router = APIRouter(prefix="/orchestrate", tags=["orchestrator"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# In-memory job store # In-memory job store
# Jobs are keyed by UUID. For this phase, memory is fine — jobs are short-lived.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_jobs: dict[str, dict] = {} _jobs: dict[str, dict] = {}
_jobs_lock = asyncio.Lock() _jobs_lock = asyncio.Lock()
# Checkpoints are stored separately — they hold Python objects (types.Content, etc.)
# that can't be included in the JSON-serializable job dict.
_checkpoints: dict[str, orchestrator_engine.OrchestrateCheckpoint] = {}
_checkpoints_lock = asyncio.Lock()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Request / response models # Request / response models
@@ -57,7 +61,7 @@ class OrchestrateRequest(BaseModel):
class OrchestrateResponse(BaseModel): class OrchestrateResponse(BaseModel):
job_id: str job_id: str
status: str # "queued" | "running" | "complete" | "error" status: str # "queued" | "running" | "complete" | "error" | "awaiting_confirmation"
class JobStatusResponse(BaseModel): class JobStatusResponse(BaseModel):
@@ -72,6 +76,7 @@ class JobStatusResponse(BaseModel):
backend: str | None = None backend: str | None = None
gemini_summary: str | None = None gemini_summary: str | None = None
error: str | None = None error: str | None = None
pending_confirmation: dict | None = None # {tools: [{name, args}], message: str}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -85,7 +90,6 @@ async def orchestrate(req: OrchestrateRequest) -> OrchestrateResponse:
user, persona = validate_persona(req.user, req.persona) user, persona = validate_persona(req.user, req.persona)
set_context(user, persona) set_context(user, persona)
except ValueError as e: except ValueError as e:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
@@ -97,17 +101,19 @@ async def orchestrate(req: OrchestrateRequest) -> OrchestrateResponse:
"task": req.task, "task": req.task,
"created_at": now, "created_at": now,
"completed_at": None, "completed_at": None,
"session_id": None,
"response": None, "response": None,
"tool_calls": None, "tool_calls": None,
"backend": None, "backend": None,
"gemini_summary": None, "gemini_summary": None,
"error": None, "error": None,
"pending_confirmation": None,
"_user": user,
} }
async with _jobs_lock: async with _jobs_lock:
_jobs[job_id] = job _jobs[job_id] = job
# Run in background — caller polls GET /orchestrate/{job_id}
asyncio.create_task(_run_job(job_id, req, user)) asyncio.create_task(_run_job(job_id, req, user))
logger.info("Orchestrator job queued: %s%.80s", job_id, req.task) logger.info("Orchestrator job queued: %s%.80s", job_id, req.task)
return OrchestrateResponse(job_id=job_id, status="queued") return OrchestrateResponse(job_id=job_id, status="queued")
@@ -120,10 +126,9 @@ async def job_status(job_id: str) -> JobStatusResponse:
job = _jobs.get(job_id) job = _jobs.get(job_id)
if job is None: if job is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Job {job_id} not found") raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
return JobStatusResponse(**job) return JobStatusResponse(**{k: v for k, v in job.items() if not k.startswith("_")})
@router.get("", response_model=list[JobStatusResponse]) @router.get("", response_model=list[JobStatusResponse])
@@ -131,11 +136,55 @@ async def list_jobs() -> list[JobStatusResponse]:
"""List all jobs (most recent first). Useful for debugging.""" """List all jobs (most recent first). Useful for debugging."""
async with _jobs_lock: async with _jobs_lock:
jobs = sorted(_jobs.values(), key=lambda j: j["created_at"], reverse=True) jobs = sorted(_jobs.values(), key=lambda j: j["created_at"], reverse=True)
return [JobStatusResponse(**j) for j in jobs] return [JobStatusResponse(**{k: v for k, v in j.items() if not k.startswith("_")}) for j in jobs]
@router.post("/{job_id}/confirm", response_model=OrchestrateResponse)
async def confirm_job(job_id: str) -> OrchestrateResponse:
"""Confirm a pending tool call — the blocked tool will execute and the job continues."""
async with _checkpoints_lock:
checkpoint = _checkpoints.pop(job_id, None)
if checkpoint is None:
raise HTTPException(status_code=404, detail="No pending confirmation for this job")
async with _jobs_lock:
job = _jobs.get(job_id)
if not job or job["status"] != "awaiting_confirmation":
raise HTTPException(status_code=409, detail="Job is not awaiting confirmation")
_jobs[job_id]["status"] = "running"
_jobs[job_id]["pending_confirmation"] = None
user = job.get("_user", "scott")
asyncio.create_task(_resume_job(job_id, checkpoint, confirmed=True, user=user))
logger.info("Orchestrator job %s confirmed — resuming", job_id)
return OrchestrateResponse(job_id=job_id, status="running")
@router.post("/{job_id}/deny", response_model=OrchestrateResponse)
async def deny_job(job_id: str) -> OrchestrateResponse:
"""Deny a pending tool call — the tool is skipped and the job produces a final response."""
async with _checkpoints_lock:
checkpoint = _checkpoints.pop(job_id, None)
if checkpoint is None:
raise HTTPException(status_code=404, detail="No pending confirmation for this job")
async with _jobs_lock:
job = _jobs.get(job_id)
if not job or job["status"] != "awaiting_confirmation":
raise HTTPException(status_code=409, detail="Job is not awaiting confirmation")
_jobs[job_id]["status"] = "running"
_jobs[job_id]["pending_confirmation"] = None
user = job.get("_user", "scott")
asyncio.create_task(_resume_job(job_id, checkpoint, confirmed=False, user=user))
logger.info("Orchestrator job %s denied — resuming with skip", job_id)
return OrchestrateResponse(job_id=job_id, status="running")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Background runner # Background runners
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None: async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
@@ -146,7 +195,6 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
try: try:
from session_store import load as load_session, save as save_session, generate_session_id from session_store import load as load_session, save as save_session, generate_session_id
# Load Inara's system prompt (same as the chat router does)
tier = req.tier or settings.default_tier tier = req.tier or settings.default_tier
system_prompt = load_context( system_prompt = load_context(
tier, tier,
@@ -155,16 +203,17 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
include_short=req.include_short, include_short=req.include_short,
) )
# Load session history if a session_id was provided
session_id = req.session_id or generate_session_id() session_id = req.session_id or generate_session_id()
history = load_session(session_id) history = load_session(session_id)
session_messages = history or None session_messages = history or None
# Choose engine based on the orchestrator role in the model registry
orch_model = model_registry.get_model_for_role(user, "orchestrator") orch_model = model_registry.get_model_for_role(user, "orchestrator")
user_role = get_user_role(user) user_role = get_user_role(user)
policy = get_tool_policy(user)
confirm_allow = set(policy.get("allow", []))
confirm_deny = set(policy.get("deny", []))
if orch_model and orch_model.get("type") == "local_openai": if orch_model and orch_model.get("type") == "local_openai":
result = await openai_orchestrator.run( result = await openai_orchestrator.run(
task=req.task, task=req.task,
@@ -173,10 +222,10 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
model_cfg=orch_model, model_cfg=orch_model,
respond_with_final=req.respond_with_claude, respond_with_final=req.respond_with_claude,
user_role=user_role, user_role=user_role,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
) )
else: else:
# Use the API key embedded in the resolved model config (V2 registry with
# account_id), then fall back to the per-user key from auth.json, then .env.
gemini_key = ( gemini_key = (
(orch_model.get("api_key") if orch_model else None) (orch_model.get("api_key") if orch_model else None)
or get_user_gemini_key(user) or get_user_gemini_key(user)
@@ -190,28 +239,31 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
model_name=orch_model.get("model_name") if orch_model else None, model_name=orch_model.get("model_name") if orch_model else None,
response_role=req.chat_role, response_role=req.chat_role,
user_role=user_role, user_role=user_role,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
) )
# Save the turn to the session store so it survives a page refresh if result.checkpoint:
history.append({"role": "user", "content": req.task}) async with _checkpoints_lock:
history.append({"role": "assistant", "content": result.response}) _checkpoints[job_id] = result.checkpoint
save_session(session_id, history) async with _jobs_lock:
_jobs[job_id].update({
"status": "awaiting_confirmation",
"response": result.response,
"tool_calls": result.tool_calls,
"backend": result.backend,
"gemini_summary": result.gemini_summary,
"session_id": session_id,
"pending_confirmation": {
"tools": result.checkpoint.pending_tools,
"message": result.response,
},
})
logger.info("Orchestrator job %s awaiting confirmation — %d tool(s) blocked",
job_id, len(result.checkpoint.pending_tools))
return
from session_logger import log_turn await _finalize_job(job_id, result, session_id, req.task, history)
log_turn(session_id, req.task, result.response)
now = datetime.now(timezone.utc).isoformat()
async with _jobs_lock:
_jobs[job_id].update({
"status": "complete",
"completed_at": now,
"session_id": session_id,
"response": result.response,
"tool_calls": result.tool_calls,
"backend": result.backend,
"gemini_summary": result.gemini_summary,
})
logger.info("Orchestrator job complete: %s (%d tool calls)", job_id, len(result.tool_calls))
except Exception as e: except Exception as e:
logger.exception("Orchestrator job failed: %s", job_id) logger.exception("Orchestrator job failed: %s", job_id)
@@ -222,3 +274,87 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
"completed_at": now, "completed_at": now,
"error": str(e), "error": str(e),
}) })
async def _resume_job(
job_id: str,
checkpoint: orchestrator_engine.OrchestrateCheckpoint,
confirmed: bool,
user: str,
) -> None:
"""Resume a job after the user confirms or denies a pending tool call."""
try:
if checkpoint.engine == "gemini":
result = await orchestrator_engine.resume(checkpoint, confirmed)
else:
result = await openai_orchestrator.resume(checkpoint, confirmed)
if result.checkpoint:
# Another confirmation needed (chained gates)
async with _checkpoints_lock:
_checkpoints[job_id] = result.checkpoint
async with _jobs_lock:
_jobs[job_id].update({
"status": "awaiting_confirmation",
"response": result.response,
"tool_calls": result.tool_calls,
"backend": result.backend,
"gemini_summary": result.gemini_summary,
"pending_confirmation": {
"tools": result.checkpoint.pending_tools,
"message": result.response,
},
})
logger.info("Orchestrator job %s awaiting another confirmation", job_id)
return
async with _jobs_lock:
session_id = _jobs[job_id].get("session_id") or ""
task = _jobs[job_id].get("task", "")
from session_store import load as load_session
history = load_session(session_id) if session_id else []
await _finalize_job(job_id, result, session_id, task, history)
except Exception as e:
logger.exception("Orchestrator resume failed: %s", job_id)
now = datetime.now(timezone.utc).isoformat()
async with _jobs_lock:
_jobs[job_id].update({
"status": "error",
"completed_at": now,
"error": str(e),
})
async def _finalize_job(
job_id: str,
result: orchestrator_engine.OrchestratorResult,
session_id: str,
task: str,
history: list,
) -> None:
"""Save session, log the turn, and mark the job complete."""
from session_store import save as save_session, generate_session_id
from session_logger import log_turn
if not session_id:
session_id = generate_session_id()
history.append({"role": "user", "content": task})
history.append({"role": "assistant", "content": result.response})
save_session(session_id, history)
log_turn(session_id, task, result.response)
now = datetime.now(timezone.utc).isoformat()
async with _jobs_lock:
_jobs[job_id].update({
"status": "complete",
"completed_at": now,
"session_id": session_id,
"response": result.response,
"tool_calls": result.tool_calls,
"backend": result.backend,
"gemini_summary": result.gemini_summary,
})
logger.info("Orchestrator job complete: %s (%d tool calls)", job_id, len(result.tool_calls))

View File

@@ -18,7 +18,8 @@ import jwt
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password, _read_auth, _write_auth, get_user_channels from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password, _read_auth, _write_auth, get_user_channels, get_tool_policy, save_tool_policy
from tools import CONFIRM_REQUIRED
from persona import list_user_personas from persona import list_user_personas
from config import settings as app_settings from config import settings as app_settings
@@ -84,6 +85,15 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
html = html.replace("{{ nc_notify_room }}", nc_room) html = html.replace("{{ nc_notify_room }}", nc_room)
html = html.replace("{{ gc_webhook }}", gc_webhook) html = html.replace("{{ gc_webhook }}", gc_webhook)
# Tool permission policy
policy = get_tool_policy(username)
tool_allow_text = _html.escape("\n".join(policy.get("allow", [])))
tool_deny_text = _html.escape("\n".join(policy.get("deny", [])))
confirm_tools_list = _html.escape(", ".join(sorted(CONFIRM_REQUIRED)))
html = html.replace("{{ tool_allow }}", tool_allow_text)
html = html.replace("{{ tool_deny }}", tool_deny_text)
html = html.replace("{{ confirm_required_tools }}", confirm_tools_list)
persona_items = "\n".join( persona_items = "\n".join(
f'''<li> f'''<li>
<a href="/{username}/{p}" class="persona-link">{p}</a> <a href="/{username}/{p}" class="persona-link">{p}</a>
@@ -302,6 +312,27 @@ async def save_notifications(
success="Notification settings saved.")) success="Notification settings saved."))
@router.post("/settings/tool-policy", include_in_schema=False)
async def save_tool_policy_route(
request: Request,
allow_list: str = Form(""),
deny_list: str = Form(""),
):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
back_persona = _preferred_persona(request, username)
allow_tools = [ln.strip() for ln in allow_list.splitlines() if ln.strip()]
deny_tools = [ln.strip() for ln in deny_list.splitlines() if ln.strip()]
save_tool_policy(username, {"allow": allow_tools, "deny": deny_tools})
logger.info("tool policy updated for %s (allow=%d deny=%d)", username, len(allow_tools), len(deny_tools))
return HTMLResponse(_settings_page(username, personas, back_persona,
success="Tool permission policy saved."))
@router.post("/settings/email-allowlist", include_in_schema=False) @router.post("/settings/email-allowlist", include_in_schema=False)
async def save_email_allowlist( async def save_email_allowlist(
request: Request, request: Request,

View File

@@ -1192,6 +1192,37 @@
: '⚡ working…'; : '⚡ working…';
continue; continue;
} }
if (job.status === 'awaiting_confirmation') {
const pc = job.pending_confirmation || {};
const toolNames = (pc.tools || []).map(t => t.name).join(', ');
thinkingDiv.className = 'message assistant';
thinkingDiv.innerHTML = `<div class="confirm-gate">
<p>${escapeHtml(pc.message || 'Confirm this action?')}</p>
<p class="confirm-tools">Tool${(pc.tools||[]).length !== 1 ? 's' : ''}: <code>${escapeHtml(toolNames)}</code></p>
<div class="confirm-actions">
<button class="confirm-btn">Confirm</button>
<button class="deny-btn">Deny</button>
</div>
</div>`;
const confirmed = await new Promise(resolve => {
thinkingDiv.querySelector('.confirm-btn').onclick = () => resolve(true);
thinkingDiv.querySelector('.deny-btn').onclick = () => resolve(false);
});
thinkingDiv.className = 'message assistant thinking';
thinkingDiv.textContent = confirmed ? '⚡ confirmed — continuing…' : '⚡ denied — finishing…';
const action = confirmed ? 'confirm' : 'deny';
const resumeRes = await fetch(`/orchestrate/${job_id}/${action}`, {
method: 'POST',
signal: activeController.signal,
});
if (!resumeRes.ok) throw new Error(`Resume failed: HTTP ${resumeRes.status}`);
continue;
}
break; break;
} }

View File

@@ -393,6 +393,35 @@
</form> </form>
</div> </div>
<!-- Tool Permissions -->
<div class="section">
<h2>Tool Permissions</h2>
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.5rem; line-height:1.55;">
Override the default confirmation gate for orchestrator tools.
<strong>Allow list</strong> — tools that run without asking for confirmation.
<strong>Deny list</strong> — tools that are always blocked for your account.
One tool name per line.
</p>
<p style="font-size:0.78rem; color:var(--pg-muted); margin-bottom:0.85rem;">
Tools requiring confirmation by default: <code>{{ confirm_required_tools }}</code>
</p>
<form method="POST" action="/settings/tool-policy">
<div class="form-group">
<label for="allow_list">Allow list (bypass confirmation)</label>
<textarea id="allow_list" name="allow_list" rows="3"
placeholder="reminders_clear&#10;cron_remove"
autocomplete="off" spellcheck="false">{{ tool_allow }}</textarea>
</div>
<div class="form-group">
<label for="deny_list">Deny list (always block)</label>
<textarea id="deny_list" name="deny_list" rows="3"
placeholder="shell_exec&#10;file_write"
autocomplete="off" spellcheck="false">{{ tool_deny }}</textarea>
</div>
<button type="submit">Save tool permissions</button>
</form>
</div>
<!-- Browser cache --> <!-- Browser cache -->
<div class="section"> <div class="section">
<h2>Browser Cache</h2> <h2>Browser Cache</h2>

View File

@@ -546,6 +546,25 @@
.message.thinking { color: var(--muted); font-style: italic; } .message.thinking { color: var(--muted); font-style: italic; }
/* Confirmation gate */
.confirm-gate { display: flex; flex-direction: column; gap: 0.6rem; }
.confirm-gate p { margin: 0; }
.confirm-tools { font-size: 0.82rem; color: var(--muted); }
.confirm-actions { display: flex; gap: 0.5rem; margin-top: 0.25rem; }
.confirm-btn, .deny-btn {
padding: 0.35rem 0.9rem;
border-radius: 6px;
border: none;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.confirm-btn { background: #16a34a; color: #fff; }
.confirm-btn:hover { opacity: 0.85; }
.deny-btn { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
.deny-btn:hover { border-color: var(--muted); }
/* Copy button */ /* Copy button */
.message.assistant, .message.user { position: relative; } .message.assistant, .message.user { position: relative; }