From bfcd54e0227409cdc0abbf196770671a116dbec4 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Fri, 3 Apr 2026 14:59:47 -0500 Subject: [PATCH 1/3] feat: add permission request UI for sensitive tool operations When Claude Code SDK needs user approval for sensitive operations (e.g. editing .mcp.json), the can_use_tool callback now emits a synthetic PermissionRequest tool call that halts the stream and surfaces an interactive Allow/Deny UI in the frontend. Approved operations are tracked per-session so retries succeed automatically. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/session/ask-user-question.tsx | 19 +- .../components/session/permission-request.tsx | 173 +++++++++++++++++ .../src/components/ui/stream-message.tsx | 25 ++- .../frontend/src/types/agentic-session.ts | 18 ++ .../ag_ui_claude_sdk/adapter.py | 182 +++++++++++++++++- .../ambient_runner/bridges/claude/bridge.py | 4 + .../ambient_runner/bridges/claude/session.py | 5 + 7 files changed, 411 insertions(+), 15 deletions(-) create mode 100644 components/frontend/src/components/session/permission-request.tsx diff --git a/components/frontend/src/components/session/ask-user-question.tsx b/components/frontend/src/components/session/ask-user-question.tsx index 1065acdbe..61e5b27ce 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,13 +42,7 @@ 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; -} +const hasResult = hasToolResult; export const AskUserQuestionMessage: React.FC = ({ toolUseBlock, 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..766a08507 --- /dev/null +++ b/components/frontend/src/components/session/permission-request.tsx @@ -0,0 +1,173 @@ +"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 config = STATUS_CONFIG[disabled && status !== "pending" ? status : "pending"]; + const resolvedConfig = STATUS_CONFIG[status]; + const activeConfig = disabled ? resolvedConfig : config; + 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/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..acc3f46ab 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,137 @@ 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", "") + description = "" + 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 +476,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 +739,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 +962,36 @@ 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 hasattr(message, "thread_id") and message.thread_id == _PERM_PLACEHOLDER_ID: + message.thread_id = thread_id + if hasattr(message, "run_id") and message.run_id == _PERM_PLACEHOLDER_ID: + message.run_id = run_id + yield message + + # Detect PermissionRequest halt: the ToolCallEndEvent + # for a PermissionRequest tool signals that we should + # halt just like a frontend tool. + if ( + isinstance(message, ToolCallEndEvent) + and hasattr(message, "tool_call_id") + and isinstance(message.tool_call_id, str) + 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..d3bcd32de 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) 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.""" From 0458999f31c9ecc6c121a549252b92d2c226945c Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Fri, 3 Apr 2026 15:03:50 -0500 Subject: [PATCH 2/3] refactor: simplify permission request code after review - Remove hasResult alias, use hasToolResult directly - Collapse config/resolvedConfig/activeConfig triple to single lookup - Remove redundant hasattr/isinstance guards on ToolCallEndEvent - Use getattr for placeholder ID checks - Clear _permission_worker in bridge finally block (prevent GC leak) - Remove dead description initializer - Add PermissionRequest to use-agent-status.ts waiting_input detection Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/session/ask-user-question.tsx | 4 +--- .../src/components/session/permission-request.tsx | 4 +--- components/frontend/src/hooks/use-agent-status.ts | 12 ++++++++---- .../ambient-runner/ag_ui_claude_sdk/adapter.py | 13 +++++-------- .../ambient_runner/bridges/claude/bridge.py | 3 +++ 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/components/frontend/src/components/session/ask-user-question.tsx b/components/frontend/src/components/session/ask-user-question.tsx index 61e5b27ce..d16793141 100644 --- a/components/frontend/src/components/session/ask-user-question.tsx +++ b/components/frontend/src/components/session/ask-user-question.tsx @@ -42,8 +42,6 @@ function parseQuestions(input: Record): AskUserQuestionItem[] { return []; } -const hasResult = hasToolResult; - export const AskUserQuestionMessage: React.FC = ({ toolUseBlock, resultBlock, @@ -52,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 index 766a08507..97f326aa3 100644 --- a/components/frontend/src/components/session/permission-request.tsx +++ b/components/frontend/src/components/session/permission-request.tsx @@ -92,9 +92,7 @@ export const PermissionRequestMessage: React.FC< } }; - const config = STATUS_CONFIG[disabled && status !== "pending" ? status : "pending"]; - const resolvedConfig = STATUS_CONFIG[status]; - const activeConfig = disabled ? resolvedConfig : config; + const activeConfig = STATUS_CONFIG[disabled && status !== "pending" ? status : "pending"]; const Icon = activeConfig.icon; 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/runners/ambient-runner/ag_ui_claude_sdk/adapter.py b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py index acc3f46ab..7cfca88ea 100644 --- a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py +++ b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py @@ -317,7 +317,6 @@ async def _can_use_tool( # Build a human-readable description of what Claude wants to do. file_path = input_data.get("file_path", "") command = input_data.get("command", "") - description = "" if file_path: description = f"{tool_name} on {file_path}" elif command: @@ -964,20 +963,18 @@ def flush_pending_msg(): if isinstance(message, BaseEvent): # Rewrite placeholder thread/run IDs injected by # can_use_tool (which doesn't know the real IDs). - if hasattr(message, "thread_id") and message.thread_id == _PERM_PLACEHOLDER_ID: + if getattr(message, "thread_id", None) == _PERM_PLACEHOLDER_ID: message.thread_id = thread_id - if hasattr(message, "run_id") and message.run_id == _PERM_PLACEHOLDER_ID: + if getattr(message, "run_id", None) == _PERM_PLACEHOLDER_ID: message.run_id = run_id yield message - # Detect PermissionRequest halt: the ToolCallEndEvent - # for a PermissionRequest tool signals that we should - # halt just like a frontend tool. + # PermissionRequest halt: ToolCallEndEvent with a + # perm- prefixed ID triggers the same halt as a + # frontend tool. if ( isinstance(message, ToolCallEndEvent) - and hasattr(message, "tool_call_id") - and isinstance(message.tool_call_id, str) and message.tool_call_id.startswith(_PERM_TOOL_ID_PREFIX) ): logger.debug( 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 d3bcd32de..b3dea8fec 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -233,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 = "" From 7969e3e45eeaae700ef63743a5061b5754d7bb5f Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Fri, 3 Apr 2026 20:22:44 +0000 Subject: [PATCH 3/3] fix: guard against None tool_call_id in permission request halt check Co-Authored-By: Claude Opus 4.6 --- components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py | 1 + 1 file changed, 1 insertion(+) 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 7cfca88ea..74d7b0fc0 100644 --- a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py +++ b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py @@ -975,6 +975,7 @@ def flush_pending_msg(): # frontend tool. if ( isinstance(message, ToolCallEndEvent) + and message.tool_call_id and message.tool_call_id.startswith(_PERM_TOOL_ID_PREFIX) ): logger.debug(