diff --git a/components/frontend/src/components/session/ask-user-question.tsx b/components/frontend/src/components/session/ask-user-question.tsx index 1065acdbe..d16793141 100644 --- a/components/frontend/src/components/session/ask-user-question.tsx +++ b/components/frontend/src/components/session/ask-user-question.tsx @@ -8,11 +8,12 @@ import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { HelpCircle, CheckCircle2, Send, ChevronRight } from "lucide-react"; import { formatTimestamp } from "@/lib/format-timestamp"; -import type { - ToolUseBlock, - ToolResultBlock, - AskUserQuestionItem, - AskUserQuestionInput, +import { + hasToolResult, + type ToolUseBlock, + type ToolResultBlock, + type AskUserQuestionItem, + type AskUserQuestionInput, } from "@/types/agentic-session"; export type AskUserQuestionMessageProps = { @@ -41,14 +42,6 @@ function parseQuestions(input: Record): AskUserQuestionItem[] { return []; } -function hasResult(resultBlock?: ToolResultBlock): boolean { - if (!resultBlock) return false; - const content = resultBlock.content; - if (!content) return false; - if (typeof content === "string" && content.trim() === "") return false; - return true; -} - export const AskUserQuestionMessage: React.FC = ({ toolUseBlock, resultBlock, @@ -57,7 +50,7 @@ export const AskUserQuestionMessage: React.FC = ({ isNewest = false, }) => { const questions = parseQuestions(toolUseBlock.input); - const alreadyAnswered = hasResult(resultBlock); + const alreadyAnswered = hasToolResult(resultBlock); const formattedTime = formatTimestamp(timestamp); const isMultiQuestion = questions.length > 1; diff --git a/components/frontend/src/components/session/permission-request.tsx b/components/frontend/src/components/session/permission-request.tsx new file mode 100644 index 000000000..97f326aa3 --- /dev/null +++ b/components/frontend/src/components/session/permission-request.tsx @@ -0,0 +1,171 @@ +"use client"; + +import React, { useState } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { ShieldCheck, ShieldX, ShieldAlert } from "lucide-react"; +import { formatTimestamp } from "@/lib/format-timestamp"; +import { + hasToolResult, + type ToolUseBlock, + type ToolResultBlock, + type PermissionRequestInput, +} from "@/types/agentic-session"; + +export type PermissionRequestMessageProps = { + toolUseBlock: ToolUseBlock; + resultBlock?: ToolResultBlock; + timestamp?: string; + onSubmitAnswer?: (formattedAnswer: string) => Promise; + isNewest?: boolean; +}; + +function isPermissionRequestInput( + input: Record +): input is PermissionRequestInput { + return "tool_name" in input && "key" in input; +} + +type PermissionStatus = "pending" | "approved" | "denied"; + +function deriveStatus(resultBlock?: ToolResultBlock): PermissionStatus { + if (!hasToolResult(resultBlock)) return "pending"; + const content = resultBlock?.content; + if (typeof content !== "string") return "denied"; + try { + return JSON.parse(content).approved === true ? "approved" : "denied"; + } catch { + return "denied"; + } +} + +const STATUS_CONFIG: Record = { + pending: { + icon: ShieldAlert, + avatarClass: "bg-amber-500", + borderClass: "border-l-amber-500 bg-amber-50/30 dark:bg-amber-950/10", + }, + approved: { + icon: ShieldCheck, + avatarClass: "bg-green-600", + borderClass: "border-l-green-500 bg-green-50/30 dark:bg-green-950/10", + }, + denied: { + icon: ShieldX, + avatarClass: "bg-red-600", + borderClass: "border-l-red-500 bg-red-50/30 dark:bg-red-950/10", + }, +}; + +export const PermissionRequestMessage: React.FC< + PermissionRequestMessageProps +> = ({ toolUseBlock, resultBlock, timestamp, onSubmitAnswer, isNewest = false }) => { + const input = toolUseBlock.input; + const status = deriveStatus(resultBlock); + const formattedTime = formatTimestamp(timestamp); + + const [submitted, setSubmitted] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const disabled = status !== "pending" || submitted || isSubmitting || !isNewest; + + if (!isPermissionRequestInput(input)) return null; + + const handleResponse = async (allow: boolean) => { + if (!onSubmitAnswer || disabled) return; + + const response = JSON.stringify({ + approved: allow, + tool_name: input.tool_name, + key: input.key, + }); + + try { + setIsSubmitting(true); + await onSubmitAnswer(response); + setSubmitted(true); + } finally { + setIsSubmitting(false); + } + }; + + const activeConfig = STATUS_CONFIG[disabled && status !== "pending" ? status : "pending"]; + const Icon = activeConfig.icon; + + return ( +
+
+
+
+ +
+
+ +
+ {formattedTime && ( +
+ {formattedTime} +
+ )} + +
+

+ Permission Required +

+

+ {input.description} +

+ + {(input.file_path || input.command) && ( +
+ {input.file_path || input.command} +
+ )} + + {disabled && status !== "pending" && ( +

+ {status === "approved" ? "Approved" : "Denied"} +

+ )} + + {!disabled && ( +
+ + +
+ )} +
+
+
+
+ ); +}; + +PermissionRequestMessage.displayName = "PermissionRequestMessage"; diff --git a/components/frontend/src/components/ui/stream-message.tsx b/components/frontend/src/components/ui/stream-message.tsx index 4c83e76d2..099ce6954 100644 --- a/components/frontend/src/components/ui/stream-message.tsx +++ b/components/frontend/src/components/ui/stream-message.tsx @@ -5,6 +5,7 @@ import { MessageObject, ToolUseMessages, HierarchicalToolMessage } from "@/types import { LoadingDots, Message } from "@/components/ui/message"; import { ToolMessage } from "@/components/ui/tool-message"; import { AskUserQuestionMessage } from "@/components/session/ask-user-question"; +import { PermissionRequestMessage } from "@/components/session/permission-request"; import { ThinkingMessage } from "@/components/ui/thinking-message"; import { SystemMessage } from "@/components/ui/system-message"; import { Button } from "@/components/ui/button"; @@ -20,9 +21,16 @@ export type StreamMessageProps = { currentUserId?: string; }; +function normalizeToolName(name: string): string { + return name.toLowerCase().replace(/[^a-z]/g, ""); +} + function isAskUserQuestionTool(name: string): boolean { - const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); - return normalized === "askuserquestion"; + return normalizeToolName(name) === "askuserquestion"; +} + +function isPermissionRequestTool(name: string): boolean { + return normalizeToolName(name) === "permissionrequest"; } const getRandomAgentMessage = () => { @@ -59,6 +67,19 @@ export const StreamMessage: React.FC = ({ message, onGoToRes ); } + // Render PermissionRequest with Allow/Deny buttons + if (isPermissionRequestTool(message.toolUseBlock.name)) { + return ( + + ); + } + // Check if this is a hierarchical message with children const hierarchical = message as HierarchicalToolMessage; return ( diff --git a/components/frontend/src/hooks/use-agent-status.ts b/components/frontend/src/hooks/use-agent-status.ts index b6fff8912..61486a298 100644 --- a/components/frontend/src/hooks/use-agent-status.ts +++ b/components/frontend/src/hooks/use-agent-status.ts @@ -5,9 +5,13 @@ import type { } from "@/types/agentic-session"; import type { PlatformMessage } from "@/types/agui"; -function isAskUserQuestionTool(name: string): boolean { - const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); - return normalized === "askuserquestion"; +function normalizeToolName(name: string): string { + return name.toLowerCase().replace(/[^a-z]/g, ""); +} + +function isHumanInTheLoopTool(name: string): boolean { + const normalized = normalizeToolName(name); + return normalized === "askuserquestion" || normalized === "permissionrequest"; } /** @@ -38,7 +42,7 @@ export function useAgentStatus( // Check the last tool call on this message const lastTc = msg.toolCalls[msg.toolCalls.length - 1]; - if (lastTc.function?.name && isAskUserQuestionTool(lastTc.function.name)) { + if (lastTc.function?.name && isHumanInTheLoopTool(lastTc.function.name)) { const hasResult = lastTc.result !== undefined && lastTc.result !== null && diff --git a/components/frontend/src/types/agentic-session.ts b/components/frontend/src/types/agentic-session.ts index 6e4de145d..35074b679 100755 --- a/components/frontend/src/types/agentic-session.ts +++ b/components/frontend/src/types/agentic-session.ts @@ -31,6 +31,15 @@ export type AskUserQuestionInput = { questions: AskUserQuestionItem[]; }; +// PermissionRequest tool types (synthetic tool emitted by can_use_tool callback) +export type PermissionRequestInput = { + tool_name: string; + file_path?: string; + command?: string; + description: string; + key: string; +}; + export type LLMSettings = { model: string; temperature: number; @@ -132,6 +141,15 @@ export type ToolResultBlock = { export type ContentBlock = TextBlock | ReasoningBlock | ToolUseBlock | ToolResultBlock; +/** Check whether a ToolResultBlock contains a non-empty result. */ +export function hasToolResult(resultBlock?: ToolResultBlock): boolean { + if (!resultBlock) return false; + const content = resultBlock.content; + if (!content) return false; + if (typeof content === "string" && content.trim() === "") return false; + return true; +} + export type ToolUseMessages = { type: "tool_use_messages"; toolUseBlock: ToolUseBlock; diff --git a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py index 21cc5ac16..74d7b0fc0 100644 --- a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py +++ b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py @@ -77,7 +77,11 @@ # These are HITL (human-in-the-loop) tools that require user input before # the agent can continue. The adapter treats them identically to frontend # tools registered via ``input_data.tools``. -BUILTIN_FRONTEND_TOOLS: set[str] = {"AskUserQuestion"} +BUILTIN_FRONTEND_TOOLS: set[str] = {"AskUserQuestion", "PermissionRequest"} + +# Sentinel values for synthetic PermissionRequest events. +_PERM_PLACEHOLDER_ID = "__perm__" +_PERM_TOOL_ID_PREFIX = "perm-" logger = logging.getLogger(__name__) @@ -245,6 +249,14 @@ def __init__( # stream drains them between SDK messages. self._hook_event_queue: asyncio.Queue = asyncio.Queue() + # Permission approval tracking: set of "tool_name:key" strings that + # the user has approved via the PermissionRequest UI. + self._approved_operations: set[str] = set() + + # Reference to the SessionWorker, set by the bridge before each run + # so can_use_tool can inject synthetic events into the output queue. + self._permission_worker: Any | None = None + # Background task registry (task_id -> info dict). # Populated from TaskStarted/TaskProgress/TaskNotification messages. self._task_registry: dict[str, dict[str, Any]] = {} @@ -265,6 +277,136 @@ def halted(self) -> bool: """ return self._halted + def set_permission_worker(self, worker: Any) -> None: + """Set the session worker for permission request event injection.""" + self._permission_worker = worker + + def _permission_key(self, tool_name: str, input_data: dict) -> str: + """Build a stable key for an approved operation.""" + file_path = input_data.get("file_path", "") + if file_path: + return f"{tool_name}:{file_path}" + command = input_data.get("command", "") + if command: + return f"{tool_name}:{command}" + return f"{tool_name}:*" + + async def _can_use_tool( + self, + tool_name: str, + input_data: dict, + *args: Any, + **kwargs: Any, + ) -> dict: + """Callback for Claude SDK ``can_use_tool``. + + If the operation was previously approved by the user, returns allow. + Otherwise emits a synthetic PermissionRequest tool call into the + worker's output queue (triggering the same halt-interrupt-resume + pattern used by AskUserQuestion) and returns deny so the SDK + reports the denial to Claude. When the user approves via the UI + and Claude retries, the approved set will contain the key and the + next invocation will return allow. + """ + key = self._permission_key(tool_name, input_data) + + if key in self._approved_operations: + logger.info(f"[PermissionRequest] Auto-approved (previously granted): {key}") + return {"behavior": "allow", "updatedInput": input_data} + + # Build a human-readable description of what Claude wants to do. + file_path = input_data.get("file_path", "") + command = input_data.get("command", "") + if file_path: + description = f"{tool_name} on {file_path}" + elif command: + description = f"{tool_name}: {command}" + else: + description = f"Use tool: {tool_name}" + + logger.info(f"[PermissionRequest] Requesting user approval: {description}") + + # Emit synthetic PermissionRequest tool call into the worker's + # output queue so the adapter's event loop picks it up and halts. + queue = ( + self._permission_worker.active_output_queue + if self._permission_worker is not None + else None + ) + if queue is not None: + perm_tool_call_id = f"{_PERM_TOOL_ID_PREFIX}{uuid.uuid4()}" + perm_input = { + "tool_name": tool_name, + "file_path": file_path, + "command": command, + "description": description, + "key": key, + } + # We use the same thread/run IDs as the current run — the adapter + # will fill these in from the BaseEvent pass-through path, but we + # need placeholder values since AG-UI events require them. + thread_id = _PERM_PLACEHOLDER_ID + run_id = _PERM_PLACEHOLDER_ID + ts = now_ms() + + events: list[BaseEvent] = [ + ToolCallStartEvent( + type=EventType.TOOL_CALL_START, + thread_id=thread_id, + run_id=run_id, + tool_call_id=perm_tool_call_id, + tool_call_name="PermissionRequest", + timestamp=ts, + ), + ToolCallArgsEvent( + type=EventType.TOOL_CALL_ARGS, + thread_id=thread_id, + run_id=run_id, + tool_call_id=perm_tool_call_id, + delta=json.dumps(perm_input), + ), + ToolCallEndEvent( + type=EventType.TOOL_CALL_END, + thread_id=thread_id, + run_id=run_id, + tool_call_id=perm_tool_call_id, + timestamp=ts, + ), + ] + for ev in events: + await queue.put(ev) + else: + logger.warning( + "[PermissionRequest] No active output queue — " + "permission request events dropped for: %s", + description, + ) + + return { + "behavior": "deny", + "message": ( + f"User approval required. {description}. " + "The user has been prompted — please wait for their response, " + "then retry the same operation." + ), + } + + def _handle_permission_response(self, user_message: str) -> None: + """Parse a PermissionRequest response and update approved operations.""" + try: + data = json.loads(user_message) + except (json.JSONDecodeError, TypeError): + logger.debug(f"[PermissionRequest] Non-JSON response: {user_message!r}") + return + + approved = data.get("approved", False) + key = data.get("key", "") + if approved and key: + self._approved_operations.add(key) + logger.info(f"[PermissionRequest] User approved: {key}") + else: + logger.info(f"[PermissionRequest] User denied: {key}") + async def run( self, input_data: RunAgentInput, @@ -333,6 +475,10 @@ async def run( # If the previous run halted for a frontend tool (e.g. AskUserQuestion), # emit a TOOL_CALL_RESULT so the frontend can mark the question as answered. if previous_halted_tool_call_id and user_message: + # If this was a PermissionRequest response, track the approval. + if previous_halted_tool_call_id.startswith(_PERM_TOOL_ID_PREFIX): + self._handle_permission_response(user_message) + yield ToolCallResultEvent( type=EventType.TOOL_CALL_RESULT, thread_id=thread_id, @@ -592,6 +738,10 @@ def build_options( merged[event_name] = [*merged.get(event_name, []), *matchers] merged_kwargs["hooks"] = merged + # Register can_use_tool callback so sensitive operations prompt + # the user for approval via the PermissionRequest UI. + merged_kwargs["can_use_tool"] = self._can_use_tool + # Create the options object logger.debug(f"Creating ClaudeAgentOptions with merged kwargs: {merged_kwargs}") return ClaudeAgentOptions(**merged_kwargs) @@ -811,7 +961,35 @@ def flush_pending_msg(): # directly into the message stream (e.g. stop endpoint), # yield it immediately without SDK processing. if isinstance(message, BaseEvent): + # Rewrite placeholder thread/run IDs injected by + # can_use_tool (which doesn't know the real IDs). + if getattr(message, "thread_id", None) == _PERM_PLACEHOLDER_ID: + message.thread_id = thread_id + if getattr(message, "run_id", None) == _PERM_PLACEHOLDER_ID: + message.run_id = run_id + yield message + + # PermissionRequest halt: ToolCallEndEvent with a + # perm- prefixed ID triggers the same halt as a + # frontend tool. + if ( + isinstance(message, ToolCallEndEvent) + and message.tool_call_id + and message.tool_call_id.startswith(_PERM_TOOL_ID_PREFIX) + ): + logger.debug( + f"PermissionRequest halt: {message.tool_call_id}" + ) + + # Add to pending_msg snapshot (so MESSAGES_SNAPSHOT + # includes the PermissionRequest tool call). + flush_pending_msg() + + self._halted = True + self._halted_tool_call_id = message.tool_call_id + halt_event_stream = True + continue message_count += 1 diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py index 7ebee5f17..b3dea8fec 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -177,6 +177,10 @@ async def run( # 5. Run adapter with message stream, wrapped in tracing session_label = self._session_manager.get_session_id(thread_id) or thread_id async with self._session_manager.get_lock(thread_id): + # Expose the worker to the adapter so the can_use_tool callback + # can inject synthetic events into the active output queue. + self._adapter.set_permission_worker(worker) + try: message_stream = worker.query(user_msg, session_id=session_label) @@ -229,6 +233,9 @@ async def run( # Clear the halt flag for this thread self._halted_by_thread.pop(thread_id, None) finally: + # Release worker reference so destroyed workers can be GC'd. + self._adapter.set_permission_worker(None) + # Clear caller token immediately — never persist between turns. if self._context: self._context.caller_token = "" diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py index 36b1236bc..b3b4acb40 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py @@ -95,6 +95,11 @@ def __init__( # ── lifecycle ── + @property + def active_output_queue(self) -> "asyncio.Queue | None": + """The output queue for the currently-active run (if any).""" + return self._active_output_queue + @property def is_alive(self) -> bool: """True if the background task is still running."""