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..e84327fdf --- /dev/null +++ b/components/frontend/src/components/session/permission-request.tsx @@ -0,0 +1,169 @@ +"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 [isSubmitting, setIsSubmitting] = useState(false); + const canRespond = status === "pending" && !isSubmitting && isNewest; + + if (!isPermissionRequestInput(input)) return null; + + const handleResponse = async (allow: boolean) => { + if (!onSubmitAnswer || !canRespond) return; + + const response = JSON.stringify({ + approved: allow, + tool_name: input.tool_name, + key: input.key, + }); + + try { + setIsSubmitting(true); + await onSubmitAnswer(response); + } finally { + setIsSubmitting(false); + } + }; + + const config = STATUS_CONFIG[status]; + const Icon = config.icon; + + return ( +
+
+
+
+ +
+
+ +
+ {formattedTime && ( +
+ {formattedTime} +
+ )} + +
+

+ Permission Required +

+

+ {input.description} +

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

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

+ )} + + {canRespond && ( +
+ + +
+ )} +
+
+
+
+ ); +}; + +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..ec8f34f9a 100644 --- a/components/frontend/src/components/ui/stream-message.tsx +++ b/components/frontend/src/components/ui/stream-message.tsx @@ -1,10 +1,11 @@ "use client"; import React from "react"; -import { MessageObject, ToolUseMessages, HierarchicalToolMessage } from "@/types/agentic-session"; +import { MessageObject, ToolUseMessages, HierarchicalToolMessage, normalizeToolName } from "@/types/agentic-session"; 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,11 +21,6 @@ export type StreamMessageProps = { currentUserId?: string; }; -function isAskUserQuestionTool(name: string): boolean { - const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); - return normalized === "askuserquestion"; -} - const getRandomAgentMessage = () => { const messages = [ "The agents are working together on your request...", @@ -46,8 +42,7 @@ export const StreamMessage: React.FC = ({ message, onGoToRes m != null && typeof m === "object" && "toolUseBlock" in m && "resultBlock" in m; if (isToolUsePair(message)) { - // Render AskUserQuestion with a custom interactive component - if (isAskUserQuestionTool(message.toolUseBlock.name)) { + if (normalizeToolName(message.toolUseBlock.name) === "askuserquestion") { return ( = ({ message, onGoToRes ); } + if (normalizeToolName(message.toolUseBlock.name) === "permissionrequest") { + return ( + + ); + } + // Check if this is a hierarchical message with children const hierarchical = message as HierarchicalToolMessage; return ( diff --git a/components/frontend/src/components/ui/tool-message.tsx b/components/frontend/src/components/ui/tool-message.tsx index 512fd259b..ea980794e 100644 --- a/components/frontend/src/components/ui/tool-message.tsx +++ b/components/frontend/src/components/ui/tool-message.tsx @@ -3,7 +3,7 @@ import React, { useState } from "react"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; -import { ToolResultBlock, ToolUseBlock, ToolUseMessages } from "@/types/agentic-session"; +import { ToolResultBlock, ToolUseBlock, ToolUseMessages, normalizeToolName } from "@/types/agentic-session"; import { ChevronDown, ChevronRight, @@ -309,7 +309,7 @@ const generateToolSummary = (toolName: string, input?: Record): if (!input || Object.keys(input).length === 0) return formatToolName(toolName); // AskUserQuestion - show first question text - if (toolName.toLowerCase().replace(/[^a-z]/g, "") === "askuserquestion") { + if (normalizeToolName(toolName) === "askuserquestion") { const questions = input.questions as Array<{ question: string }> | undefined; if (questions?.length) { const suffix = questions.length > 1 ? ` (+${questions.length - 1} more)` : ""; diff --git a/components/frontend/src/hooks/use-agent-status.ts b/components/frontend/src/hooks/use-agent-status.ts index b6fff8912..b4a6e1884 100644 --- a/components/frontend/src/hooks/use-agent-status.ts +++ b/components/frontend/src/hooks/use-agent-status.ts @@ -1,15 +1,11 @@ import { useMemo } from "react"; -import type { - AgenticSessionPhase, - AgentStatus, +import { + isHumanInTheLoopTool, + type AgenticSessionPhase, + type AgentStatus, } 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"; -} - /** * Derive agent status from session data and the raw AG-UI message stream. * @@ -38,7 +34,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 fa11cf12e..2b01dd7e2 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; @@ -142,6 +151,23 @@ export type ToolResultBlock = { export type ContentBlock = TextBlock | ReasoningBlock | ToolUseBlock | ToolResultBlock; +export function normalizeToolName(name: string): string { + return name.toLowerCase().replace(/[^a-z]/g, ""); +} + +export function isHumanInTheLoopTool(name: string): boolean { + const normalized = normalizeToolName(name); + return normalized === "askuserquestion" || normalized === "permissionrequest"; +} + +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..6290ce198 100644 --- a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py +++ b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py @@ -13,6 +13,9 @@ from datetime import datetime, timezone from typing import Any, AsyncIterator, TYPE_CHECKING +if TYPE_CHECKING: + from ambient_runner.bridges.claude.session import SessionWorker + # AG-UI Protocol Events from ag_ui.core import ( EventType, @@ -77,7 +80,22 @@ # 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"} + +# Tools that require user approval before execution. +# All other tools are auto-allowed without prompting. +SENSITIVE_TOOLS: set[str] = { + "Write", + "Edit", + "MultiEdit", + "Bash", + "NotebookEdit", +} + +_MAX_APPROVED_OPERATIONS = 500 + +_PERM_PLACEHOLDER_ID = "__perm__" +_PERM_TOOL_ID_PREFIX = "perm-" logger = logging.getLogger(__name__) @@ -245,6 +263,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: SessionWorker | 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 +291,138 @@ def halted(self) -> bool: """ return self._halted + def set_permission_worker(self, worker: SessionWorker | None) -> 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. + """ + if tool_name not in SENSITIVE_TOOLS: + return {"behavior": "allow", "updatedInput": input_data} + + 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} + + 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}") + + 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, + } + # Placeholder IDs — rewritten to real values in the event loop. + 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: + if len(self._approved_operations) >= _MAX_APPROVED_OPERATIONS: + self._approved_operations.clear() + 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 +491,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 +754,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) @@ -605,6 +771,7 @@ def _emit_task_event(self, message: Any) -> "CustomEvent": TaskProgressMessage, TaskNotificationMessage, ) + if isinstance(message, TaskStartedMessage): return self._emit_task_started(message) elif isinstance(message, TaskProgressMessage): @@ -631,6 +798,7 @@ def drain_hook_events(self) -> list: sid = val.get("session_id", "") if sid: from pathlib import Path + base = Path.home() / ".claude" / "projects" if base.exists(): expected = f"agent-{agent_id}.jsonl" @@ -668,7 +836,9 @@ def _emit_task_progress(self, message: Any) -> "CustomEvent": existing = self._task_registry.get(message.task_id, {}) existing.update(progress_value) self._task_registry[message.task_id] = existing - return CustomEvent(type=EventType.CUSTOM, name="task:progress", value=progress_value) + return CustomEvent( + type=EventType.CUSTOM, name="task:progress", value=progress_value + ) def _emit_task_notification(self, message: Any) -> "CustomEvent": usage = getattr(message, "usage", None) @@ -685,7 +855,9 @@ def _emit_task_notification(self, message: Any) -> "CustomEvent": self._task_registry[message.task_id] = existing if output_file: self._task_outputs[message.task_id] = output_file - return CustomEvent(type=EventType.CUSTOM, name="task:completed", value=notification_value) + return CustomEvent( + type=EventType.CUSTOM, name="task:completed", value=notification_value + ) async def _stream_claude_sdk( self, @@ -811,7 +983,33 @@ 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 @@ -1160,7 +1358,10 @@ def flush_pending_msg(): ): yield event - elif isinstance(message, (TaskStartedMessage, TaskProgressMessage, TaskNotificationMessage)): + elif isinstance( + message, + (TaskStartedMessage, TaskProgressMessage, TaskNotificationMessage), + ): yield self._emit_task_event(message) elif isinstance(message, SystemMessage): @@ -1361,4 +1562,3 @@ def flush_pending_msg(): # Re-raise to let run() emit RunErrorEvent if stream_error is not None: raise stream_error - 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 d2d5b4c54..f0d94378e 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -265,6 +265,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) @@ -317,6 +321,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."""