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:
@@ -24,8 +24,8 @@ import logging
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from config import settings
|
||||
from orchestrator_engine import OrchestratorResult
|
||||
from tools import OPENAI_TOOL_SCHEMAS, call_tool, get_openai_tools_for_role, CONFIRM_REQUIRED
|
||||
from orchestrator_engine import OrchestrateCheckpoint, OrchestratorResult
|
||||
from tools import OPENAI_TOOL_SCHEMAS, call_tool, get_openai_tools_for_role, get_tools_for_role, CONFIRM_REQUIRED
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +45,8 @@ async def run(
|
||||
model_cfg: dict | None = None,
|
||||
respond_with_final: bool = True,
|
||||
user_role: str = "user",
|
||||
confirm_allow: set[str] | None = None,
|
||||
confirm_deny: set[str] | None = None,
|
||||
) -> OrchestratorResult:
|
||||
"""
|
||||
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)
|
||||
respond_with_final: If False, return just the tool-loop summary without a
|
||||
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:
|
||||
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:
|
||||
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")
|
||||
_confirm_allow = frozenset(confirm_allow or ())
|
||||
_confirm_deny = frozenset(confirm_deny or ())
|
||||
effective_confirm = (CONFIRM_REQUIRED - set(_confirm_allow)) | set(_confirm_deny)
|
||||
|
||||
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)}"
|
||||
)
|
||||
client, model_name, active_tools = _build_client(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
|
||||
|
||||
# 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}]
|
||||
if session_messages:
|
||||
messages.extend(
|
||||
@@ -94,13 +82,132 @@ async def run(
|
||||
)
|
||||
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] = []
|
||||
|
||||
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 = ""
|
||||
|
||||
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",
|
||||
round_num + 1, settings.orchestrator_max_rounds, model_name)
|
||||
|
||||
@@ -112,29 +219,28 @@ async def run(
|
||||
)
|
||||
|
||||
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"}
|
||||
if msg.content:
|
||||
assistant_msg["content"] = msg.content
|
||||
if msg.tool_calls:
|
||||
assistant_msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
||||
}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
messages.append(assistant_msg)
|
||||
|
||||
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:
|
||||
name = tc.function.name
|
||||
@@ -143,34 +249,23 @@ async def run(
|
||||
except json.JSONDecodeError:
|
||||
args_parsed = {"raw": tc.function.arguments}
|
||||
|
||||
if name in CONFIRM_REQUIRED:
|
||||
args_str = json.dumps(args_parsed, indent=2) if args_parsed 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
|
||||
if name in effective_confirm:
|
||||
pending_tools.append({"name": name, "args": args_parsed, "tool_call_id": tc.id})
|
||||
logger.info("Tool %s blocked — confirmation required", name)
|
||||
else:
|
||||
result_str = await _execute_tool(name, tc.function.arguments, user_role)
|
||||
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({
|
||||
"tool": name,
|
||||
"args": args_parsed,
|
||||
"result": "[awaiting confirmation]" if name in CONFIRM_REQUIRED else result_str,
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": result_str,
|
||||
})
|
||||
if pending_tools:
|
||||
# Add placeholder responses
|
||||
for pt in pending_tools:
|
||||
placeholder = f"[AWAITING USER CONFIRMATION for {pt['name']}]"
|
||||
tool_call_log.append({"tool": pt["name"], "args": pt["args"], "result": "[awaiting confirmation]"})
|
||||
messages.append({"role": "tool", "tool_call_id": pt["tool_call_id"], "content": placeholder})
|
||||
|
||||
if confirm_requested:
|
||||
# One more model round to produce the confirmation-request message, then stop.
|
||||
conf_resp = await client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=messages,
|
||||
@@ -180,10 +275,24 @@ async def run(
|
||||
final_response = conf_resp.choices[0].message.content or (
|
||||
"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:
|
||||
# finish_reason == "stop" (or no tool_calls) — model is done
|
||||
final_response = msg.content or ""
|
||||
logger.info(
|
||||
"OpenAI orchestrator done after %d round(s). Tools used: %d",
|
||||
@@ -192,30 +301,37 @@ async def run(
|
||||
break
|
||||
|
||||
else:
|
||||
# Hit the round limit
|
||||
logger.warning("OpenAI orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds)
|
||||
final_response = (
|
||||
f"Reached the tool iteration limit ({settings.orchestrator_max_rounds} rounds). "
|
||||
"Here is what was gathered:\n\n"
|
||||
+ "\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)
|
||||
)
|
||||
|
||||
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 final_response, None
|
||||
|
||||
return OrchestratorResult(
|
||||
response=final_response,
|
||||
tool_calls=tool_call_log,
|
||||
backend="local",
|
||||
gemini_summary=final_response, # reused for UI display; same content in single-model mode
|
||||
)
|
||||
|
||||
def _build_client(model_cfg: dict | None) -> tuple:
|
||||
"""Build AsyncOpenAI client and return (client, model_name, active_tools)."""
|
||||
if not model_cfg:
|
||||
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:
|
||||
"""Parse tool arguments and execute with role-filtered callables."""
|
||||
from tools import get_tools_for_role
|
||||
_, callables = get_tools_for_role(user_role)
|
||||
try:
|
||||
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:
|
||||
logger.warning("Tool %s failed: %s", name, 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}"
|
||||
|
||||
Reference in New Issue
Block a user