mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
Add run-level auto permission mode
- add LLM-based auto permission classifier for permission-gated tool calls - store run-level permission mode and auto permission decision events - auto-approve low-risk calls, and bubble auto-denied calls to manual approval - show auto-denied reasons in chat and auto-approved labels below tool cards - add BYOK setting for the auto-permission decision model
This commit is contained in:
parent
8a8b78071d
commit
d47cab6a0f
15 changed files with 641 additions and 85 deletions
|
|
@ -11,7 +11,7 @@ import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown
|
||||||
import { ChatSidebar } from './components/chat-sidebar';
|
import { ChatSidebar } from './components/chat-sidebar';
|
||||||
import { ChatHeader } from './components/chat-header';
|
import { ChatHeader } from './components/chat-header';
|
||||||
import { ChatEmptyState } from './components/chat-empty-state';
|
import { ChatEmptyState } from './components/chat-empty-state';
|
||||||
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions';
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
||||||
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
||||||
|
|
@ -56,9 +56,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||||
|
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision';
|
||||||
import { TerminalOutput } from '@/components/terminal-output';
|
import { TerminalOutput } from '@/components/terminal-output';
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||||
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
|
|
@ -961,7 +962,7 @@ function App() {
|
||||||
voice.start()
|
voice.start()
|
||||||
}, [voice])
|
}, [voice])
|
||||||
|
|
||||||
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise<void>) | null>(null)
|
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise<void>) | null>(null)
|
||||||
const pendingVoiceInputRef = useRef(false)
|
const pendingVoiceInputRef = useRef(false)
|
||||||
|
|
||||||
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
|
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
|
||||||
|
|
@ -1180,6 +1181,7 @@ function App() {
|
||||||
const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
|
const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
|
||||||
// Track permission responses (toolCallId -> response)
|
// Track permission responses (toolCallId -> response)
|
||||||
const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map())
|
const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map())
|
||||||
|
const [autoPermissionDecisions, setAutoPermissionDecisions] = useState<Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>>(new Map())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chatViewStateByTabRef.current = chatViewStateByTab
|
chatViewStateByTabRef.current = chatViewStateByTab
|
||||||
|
|
@ -1193,6 +1195,7 @@ function App() {
|
||||||
pendingAskHumanRequests: new Map(pendingAskHumanRequests),
|
pendingAskHumanRequests: new Map(pendingAskHumanRequests),
|
||||||
allPermissionRequests: new Map(allPermissionRequests),
|
allPermissionRequests: new Map(allPermissionRequests),
|
||||||
permissionResponses: new Map(permissionResponses),
|
permissionResponses: new Map(permissionResponses),
|
||||||
|
autoPermissionDecisions: new Map(autoPermissionDecisions),
|
||||||
}
|
}
|
||||||
setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot }))
|
setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot }))
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -1203,6 +1206,7 @@ function App() {
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -2026,6 +2030,7 @@ function App() {
|
||||||
// Track permission requests and responses from history
|
// Track permission requests and responses from history
|
||||||
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||||
const permResponseMap = new Map<string, 'approve' | 'deny'>()
|
const permResponseMap = new Map<string, 'approve' | 'deny'>()
|
||||||
|
const autoPermissionDecisions = new Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>()
|
||||||
const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
||||||
const respondedAskHumanIds = new Set<string>()
|
const respondedAskHumanIds = new Set<string>()
|
||||||
|
|
||||||
|
|
@ -2034,6 +2039,8 @@ function App() {
|
||||||
allPermissionRequests.set(event.toolCall.toolCallId, event)
|
allPermissionRequests.set(event.toolCall.toolCallId, event)
|
||||||
} else if (event.type === 'tool-permission-response') {
|
} else if (event.type === 'tool-permission-response') {
|
||||||
permResponseMap.set(event.toolCallId, event.response)
|
permResponseMap.set(event.toolCallId, event.response)
|
||||||
|
} else if (event.type === 'tool-permission-auto-decision') {
|
||||||
|
autoPermissionDecisions.set(event.toolCallId, event)
|
||||||
} else if (event.type === 'ask-human-request') {
|
} else if (event.type === 'ask-human-request') {
|
||||||
askHumanRequests.set(event.toolCallId, event)
|
askHumanRequests.set(event.toolCallId, event)
|
||||||
} else if (event.type === 'ask-human-response') {
|
} else if (event.type === 'ask-human-response') {
|
||||||
|
|
@ -2066,6 +2073,7 @@ function App() {
|
||||||
setPendingAskHumanRequests(pendingAsks)
|
setPendingAskHumanRequests(pendingAsks)
|
||||||
setAllPermissionRequests(allPermissionRequests)
|
setAllPermissionRequests(allPermissionRequests)
|
||||||
setPermissionResponses(permResponseMap)
|
setPermissionResponses(permResponseMap)
|
||||||
|
setAutoPermissionDecisions(autoPermissionDecisions)
|
||||||
|
|
||||||
// Restore the run's per-chat work directory into the tab it was loaded into.
|
// Restore the run's per-chat work directory into the tab it was loaded into.
|
||||||
const tabId = activeChatTabIdRef.current
|
const tabId = activeChatTabIdRef.current
|
||||||
|
|
@ -2375,6 +2383,16 @@ function App() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'tool-permission-auto-decision': {
|
||||||
|
if (!isActiveRun) return
|
||||||
|
setAutoPermissionDecisions(prev => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(event.toolCallId, event)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'ask-human-request': {
|
case 'ask-human-request': {
|
||||||
if (!isActiveRun) return
|
if (!isActiveRun) return
|
||||||
const key = event.toolCallId
|
const key = event.toolCallId
|
||||||
|
|
@ -2491,6 +2509,7 @@ function App() {
|
||||||
stagedAttachments: StagedAttachment[] = [],
|
stagedAttachments: StagedAttachment[] = [],
|
||||||
searchEnabled?: boolean,
|
searchEnabled?: boolean,
|
||||||
codeMode?: 'claude' | 'codex',
|
codeMode?: 'claude' | 'codex',
|
||||||
|
permissionMode?: PermissionMode,
|
||||||
) => {
|
) => {
|
||||||
if (isProcessing) return
|
if (isProcessing) return
|
||||||
|
|
||||||
|
|
@ -2530,6 +2549,7 @@ function App() {
|
||||||
const run = await window.ipc.invoke('runs:create', {
|
const run = await window.ipc.invoke('runs:create', {
|
||||||
agentId,
|
agentId,
|
||||||
...(selected ? { model: selected.model, provider: selected.provider } : {}),
|
...(selected ? { model: selected.model, provider: selected.provider } : {}),
|
||||||
|
permissionMode: permissionMode ?? 'manual',
|
||||||
})
|
})
|
||||||
currentRunId = run.id
|
currentRunId = run.id
|
||||||
newRunCreatedAt = run.createdAt
|
newRunCreatedAt = run.createdAt
|
||||||
|
|
@ -2734,6 +2754,7 @@ function App() {
|
||||||
setPendingAskHumanRequests(new Map())
|
setPendingAskHumanRequests(new Map())
|
||||||
setAllPermissionRequests(new Map())
|
setAllPermissionRequests(new Map())
|
||||||
setPermissionResponses(new Map())
|
setPermissionResponses(new Map())
|
||||||
|
setAutoPermissionDecisions(new Map())
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setChatViewportAnchor(activeChatTabIdRef.current, null)
|
setChatViewportAnchor(activeChatTabIdRef.current, null)
|
||||||
setChatViewStateByTab(prev => ({
|
setChatViewStateByTab(prev => ({
|
||||||
|
|
@ -2760,6 +2781,7 @@ function App() {
|
||||||
setPendingAskHumanRequests(new Map())
|
setPendingAskHumanRequests(new Map())
|
||||||
setAllPermissionRequests(new Map())
|
setAllPermissionRequests(new Map())
|
||||||
setPermissionResponses(new Map())
|
setPermissionResponses(new Map())
|
||||||
|
setAutoPermissionDecisions(new Map())
|
||||||
setChatViewportAnchor(tab.id, null)
|
setChatViewportAnchor(tab.id, null)
|
||||||
}
|
}
|
||||||
}, [loadRun, setChatViewportAnchor])
|
}, [loadRun, setChatViewportAnchor])
|
||||||
|
|
@ -2785,6 +2807,7 @@ function App() {
|
||||||
setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests))
|
setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests))
|
||||||
setAllPermissionRequests(new Map(cached.allPermissionRequests))
|
setAllPermissionRequests(new Map(cached.allPermissionRequests))
|
||||||
setPermissionResponses(new Map(cached.permissionResponses))
|
setPermissionResponses(new Map(cached.permissionResponses))
|
||||||
|
setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions))
|
||||||
setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId)))
|
setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId)))
|
||||||
return true
|
return true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -5057,7 +5080,11 @@ function App() {
|
||||||
}
|
}
|
||||||
}, [isGraphOpen, knowledgeFilePaths])
|
}, [isGraphOpen, knowledgeFilePaths])
|
||||||
|
|
||||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
const renderConversationItem = (
|
||||||
|
item: ConversationItem,
|
||||||
|
tabId: string,
|
||||||
|
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
|
||||||
|
) => {
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
if (item.role === 'user') {
|
if (item.role === 'user') {
|
||||||
if (item.attachments && item.attachments.length > 0) {
|
if (item.attachments && item.attachments.length > 0) {
|
||||||
|
|
@ -5155,6 +5182,7 @@ function App() {
|
||||||
key={item.id}
|
key={item.id}
|
||||||
open={isToolOpenForTab(tabId, item.id)}
|
open={isToolOpenForTab(tabId, item.id)}
|
||||||
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
||||||
|
autoPermissionDetail={options?.autoPermissionDetail}
|
||||||
>
|
>
|
||||||
<ToolHeader
|
<ToolHeader
|
||||||
title={toolTitle}
|
title={toolTitle}
|
||||||
|
|
@ -5197,6 +5225,7 @@ function App() {
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
}), [
|
}), [
|
||||||
runId,
|
runId,
|
||||||
conversation,
|
conversation,
|
||||||
|
|
@ -5204,6 +5233,7 @@ function App() {
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
])
|
])
|
||||||
const emptyChatTabState = React.useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
const emptyChatTabState = React.useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
||||||
const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
|
const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
|
||||||
|
|
@ -5790,7 +5820,7 @@ function App() {
|
||||||
<>
|
<>
|
||||||
{groupConversationItems(
|
{groupConversationItems(
|
||||||
tabState.conversation,
|
tabState.conversation,
|
||||||
(id) => !!tabState.allPermissionRequests.get(id)
|
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
|
||||||
).map(item => {
|
).map(item => {
|
||||||
if (isToolGroup(item)) {
|
if (isToolGroup(item)) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -5802,41 +5832,61 @@ function App() {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const rendered = renderConversationItem(item, tab.id)
|
const autoDecision = isToolCall(item)
|
||||||
|
? tabState.autoPermissionDecisions.get(item.id)
|
||||||
|
: undefined
|
||||||
|
const rendered = renderConversationItem(
|
||||||
|
item,
|
||||||
|
tab.id,
|
||||||
|
autoDecision?.decision === 'allow'
|
||||||
|
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
if (isToolCall(item)) {
|
if (isToolCall(item)) {
|
||||||
|
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
|
||||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||||
if (permRequest) {
|
if (deniedAutoDecision || permRequest) {
|
||||||
const response = tabState.permissionResponses.get(item.id) || null
|
const response = tabState.permissionResponses.get(item.id) || null
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
<PermissionRequest
|
{deniedAutoDecision && (
|
||||||
toolCall={permRequest.toolCall}
|
<AutoPermissionDecision
|
||||||
permission={permRequest.permission}
|
toolCall={deniedAutoDecision.toolCall}
|
||||||
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
permission={deniedAutoDecision.permission}
|
||||||
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
decision={deniedAutoDecision.decision}
|
||||||
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
reason={deniedAutoDecision.reason}
|
||||||
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
/>
|
||||||
onSwitchAgent={async (newAgent) => {
|
)}
|
||||||
const runIdForSwitch = tab.runId
|
{permRequest && (
|
||||||
await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')
|
<PermissionRequest
|
||||||
window.dispatchEvent(new CustomEvent('code-mode-detected', {
|
toolCall={permRequest.toolCall}
|
||||||
detail: { runId: runIdForSwitch, agent: newAgent },
|
permission={permRequest.permission}
|
||||||
}))
|
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||||
if (runIdForSwitch) {
|
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||||
try {
|
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||||
await window.ipc.invoke('runs:createMessage', {
|
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||||
runId: runIdForSwitch,
|
onSwitchAgent={async (newAgent) => {
|
||||||
message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`,
|
const runIdForSwitch = tab.runId
|
||||||
codeMode: newAgent,
|
await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')
|
||||||
})
|
window.dispatchEvent(new CustomEvent('code-mode-detected', {
|
||||||
} catch (err) {
|
detail: { runId: runIdForSwitch, agent: newAgent },
|
||||||
console.error('Failed to send swap-agent follow-up', err)
|
}))
|
||||||
|
if (runIdForSwitch) {
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke('runs:createMessage', {
|
||||||
|
runId: runIdForSwitch,
|
||||||
|
message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`,
|
||||||
|
codeMode: newAgent,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send swap-agent follow-up', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
isProcessing={isActive && isProcessing}
|
||||||
isProcessing={isActive && isProcessing}
|
response={response}
|
||||||
response={response}
|
/>
|
||||||
/>
|
)}
|
||||||
{rendered}
|
{rendered}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
|
|
@ -5989,6 +6039,7 @@ function App() {
|
||||||
pendingAskHumanRequests={pendingAskHumanRequests}
|
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||||
allPermissionRequests={allPermissionRequests}
|
allPermissionRequests={allPermissionRequests}
|
||||||
permissionResponses={permissionResponses}
|
permissionResponses={permissionResponses}
|
||||||
|
autoPermissionDecisions={autoPermissionDecisions}
|
||||||
onPermissionResponse={handlePermissionResponse}
|
onPermissionResponse={handlePermissionResponse}
|
||||||
onAskHumanResponse={handleAskHumanResponse}
|
onAskHumanResponse={handleAskHumanResponse}
|
||||||
isToolOpenForTab={isToolOpenForTab}
|
isToolOpenForTab={isToolOpenForTab}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckCircle2Icon, ShieldAlertIcon, Terminal } from "lucide-react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||||
|
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export type AutoPermissionDecisionProps = ComponentProps<"div"> & {
|
||||||
|
toolCall: z.infer<typeof ToolCallPart>;
|
||||||
|
decision: "allow" | "deny";
|
||||||
|
reason: string;
|
||||||
|
permission?: z.infer<typeof ToolPermissionMetadata>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileActionLabels: Record<string, string> = {
|
||||||
|
read: "Read file",
|
||||||
|
list: "List folder",
|
||||||
|
search: "Search files",
|
||||||
|
write: "Write files",
|
||||||
|
delete: "Delete path",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AutoPermissionDecision({
|
||||||
|
className,
|
||||||
|
toolCall,
|
||||||
|
decision,
|
||||||
|
reason,
|
||||||
|
permission,
|
||||||
|
...props
|
||||||
|
}: AutoPermissionDecisionProps) {
|
||||||
|
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
|
||||||
|
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
|
||||||
|
? String(toolCall.arguments.command)
|
||||||
|
: JSON.stringify(toolCall.arguments))
|
||||||
|
: null;
|
||||||
|
const filePermission = permission?.kind === "file" ? permission : null;
|
||||||
|
const allowed = decision === "allow";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"not-prose mb-4 w-full rounded-md border",
|
||||||
|
allowed
|
||||||
|
? "border-green-500/50 bg-green-50/80 dark:border-green-500/35 dark:bg-green-950/30"
|
||||||
|
: "border-[#fa2525]/60 bg-[#fa2525]/15 dark:border-[#fa2525]/50 dark:bg-[#fa2525]/20",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{allowed ? (
|
||||||
|
<CheckCircle2Icon className="mt-0.5 size-5 shrink-0 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlertIcon className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{allowed ? "Auto Allowed" : "Auto Denied"}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary" className="bg-secondary text-foreground">
|
||||||
|
<Terminal className="mr-1 size-3" />
|
||||||
|
{toolCall.toolName}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{reason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{command && (
|
||||||
|
<div className="rounded-md border bg-background/50 p-3">
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Command</p>
|
||||||
|
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">{command}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filePermission && (
|
||||||
|
<div className="space-y-3 rounded-md border bg-background/50 p-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Action</p>
|
||||||
|
<p className="text-xs font-medium text-foreground">
|
||||||
|
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Path{filePermission.paths.length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">
|
||||||
|
{filePermission.paths.join("\n")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,18 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ToolUIPart } from "ai";
|
import type { ToolUIPart } from "ai";
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||||
|
|
@ -45,17 +51,51 @@ const ToolCode = ({
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
export type ToolAutoPermissionDetail = {
|
||||||
|
decision: "allow";
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
export type ToolProps = ComponentProps<typeof Collapsible> & {
|
||||||
<Collapsible
|
autoPermissionDetail?: ToolAutoPermissionDetail;
|
||||||
className={cn(
|
};
|
||||||
"not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
|
|
||||||
className
|
export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => {
|
||||||
)}
|
const toolCard = (
|
||||||
{...props}
|
<Collapsible
|
||||||
/>
|
className={cn(
|
||||||
);
|
autoPermissionDetail
|
||||||
|
? "w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
||||||
|
: "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!autoPermissionDetail) return toolCard;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="not-prose mb-4 w-full">
|
||||||
|
{toolCard}
|
||||||
|
<div className="mt-1 flex justify-end px-3">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex cursor-help items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
<ShieldCheckIcon className="size-3 text-muted-foreground/70" />
|
||||||
|
Auto-approved
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="end" className="max-w-sm">
|
||||||
|
{autoPermissionDetail.reason}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type ToolHeaderProps = {
|
export type ToolHeaderProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
Mic,
|
Mic,
|
||||||
Plus,
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
Square,
|
Square,
|
||||||
Terminal,
|
Terminal,
|
||||||
X,
|
X,
|
||||||
|
|
@ -85,6 +86,8 @@ export interface SelectedModel {
|
||||||
model: string
|
model: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PermissionMode = 'manual' | 'auto'
|
||||||
|
|
||||||
function getSelectedModelDisplayName(model: string) {
|
function getSelectedModelDisplayName(model: string) {
|
||||||
return model.split('/').pop() || model
|
return model.split('/').pop() || model
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +112,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatInputInnerProps {
|
interface ChatInputInnerProps {
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
|
|
@ -182,11 +185,13 @@ function ChatInputInner({
|
||||||
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
||||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||||
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||||
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
|
||||||
|
|
||||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
setLockedModel(null)
|
setLockedModel(null)
|
||||||
|
setPermissionMode('auto')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -195,6 +200,7 @@ function ChatInputInner({
|
||||||
if (run.provider && run.model) {
|
if (run.provider && run.model) {
|
||||||
setLockedModel({ provider: run.provider, model: run.model })
|
setLockedModel({ provider: run.provider, model: run.model })
|
||||||
}
|
}
|
||||||
|
setPermissionMode(run.permissionMode ?? 'manual')
|
||||||
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [runId])
|
}, [runId])
|
||||||
|
|
@ -482,13 +488,13 @@ function ChatInputInner({
|
||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
// codeMode is sticky per conversation — don't reset after send.
|
// codeMode is sticky per conversation — don't reset after send.
|
||||||
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
|
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
|
||||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode)
|
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode)
|
||||||
controller.textInput.clear()
|
controller.textInput.clear()
|
||||||
controller.mentions.clearMentions()
|
controller.mentions.clearMentions()
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
// Web search toggle stays on for the rest of the chat session; the user
|
// Web search toggle stays on for the rest of the chat session; the user
|
||||||
// turns it off explicitly. (Not persisted across app restarts.)
|
// turns it off explicitly. (Not persisted across app restarts.)
|
||||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, workDir])
|
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir])
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|
@ -709,6 +715,36 @@ function ChatInputInner({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (runId) return
|
||||||
|
setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto')
|
||||||
|
}}
|
||||||
|
disabled={Boolean(runId)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
|
||||||
|
permissionMode === 'auto'
|
||||||
|
? "bg-secondary text-foreground hover:bg-secondary/70"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
runId && "cursor-not-allowed opacity-70 hover:bg-secondary"
|
||||||
|
)}
|
||||||
|
aria-label="Permission mode"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
<span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{runId
|
||||||
|
? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}`
|
||||||
|
: permissionMode === 'auto'
|
||||||
|
? 'Auto-permission on — click for manual approval prompts'
|
||||||
|
: 'Manual approval prompts — click for auto-permission'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
{codeModeFeatureEnabled && (codeModeEnabled ? (
|
{codeModeFeatureEnabled && (codeModeEnabled ? (
|
||||||
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -915,7 +951,7 @@ export interface ChatInputWithMentionsProps {
|
||||||
knowledgeFiles: string[]
|
knowledgeFiles: string[]
|
||||||
recentFiles: string[]
|
recentFiles: string[]
|
||||||
visibleFiles: string[]
|
visibleFiles: string[]
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent }
|
||||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||||
|
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'
|
||||||
import { TerminalOutput } from '@/components/terminal-output'
|
import { TerminalOutput } from '@/components/terminal-output'
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||||
|
|
@ -36,7 +37,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
|
||||||
import { defaultRemarkPlugins } from 'streamdown'
|
import { defaultRemarkPlugins } from 'streamdown'
|
||||||
import remarkBreaks from 'remark-breaks'
|
import remarkBreaks from 'remark-breaks'
|
||||||
import { type ChatTab } from '@/components/tab-bar'
|
import { type ChatTab } from '@/components/tab-bar'
|
||||||
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { useSidebar } from '@/components/ui/sidebar'
|
import { useSidebar } from '@/components/ui/sidebar'
|
||||||
import { wikiLabel } from '@/lib/wiki-links'
|
import { wikiLabel } from '@/lib/wiki-links'
|
||||||
|
|
@ -139,7 +140,7 @@ interface ChatSidebarProps {
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||||
knowledgeFiles?: string[]
|
knowledgeFiles?: string[]
|
||||||
recentFiles?: string[]
|
recentFiles?: string[]
|
||||||
visibleFiles?: string[]
|
visibleFiles?: string[]
|
||||||
|
|
@ -154,6 +155,7 @@ interface ChatSidebarProps {
|
||||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||||
|
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
|
||||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
|
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
|
||||||
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
||||||
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
||||||
|
|
@ -211,6 +213,7 @@ export function ChatSidebar({
|
||||||
pendingAskHumanRequests = new Map(),
|
pendingAskHumanRequests = new Map(),
|
||||||
allPermissionRequests = new Map(),
|
allPermissionRequests = new Map(),
|
||||||
permissionResponses = new Map(),
|
permissionResponses = new Map(),
|
||||||
|
autoPermissionDecisions = new Map(),
|
||||||
onPermissionResponse,
|
onPermissionResponse,
|
||||||
onAskHumanResponse,
|
onAskHumanResponse,
|
||||||
isToolOpenForTab,
|
isToolOpenForTab,
|
||||||
|
|
@ -325,6 +328,7 @@ export function ChatSidebar({
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
}), [
|
}), [
|
||||||
runId,
|
runId,
|
||||||
conversation,
|
conversation,
|
||||||
|
|
@ -332,6 +336,7 @@ export function ChatSidebar({
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
])
|
])
|
||||||
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
||||||
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
||||||
|
|
@ -358,7 +363,11 @@ export function ChatSidebar({
|
||||||
}
|
}
|
||||||
}, [activeRunId])
|
}, [activeRunId])
|
||||||
|
|
||||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
const renderConversationItem = (
|
||||||
|
item: ConversationItem,
|
||||||
|
tabId: string,
|
||||||
|
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
|
||||||
|
) => {
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
if (item.role === 'user') {
|
if (item.role === 'user') {
|
||||||
if (item.attachments && item.attachments.length > 0) {
|
if (item.attachments && item.attachments.length > 0) {
|
||||||
|
|
@ -451,6 +460,7 @@ export function ChatSidebar({
|
||||||
key={item.id}
|
key={item.id}
|
||||||
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
||||||
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
||||||
|
autoPermissionDetail={options?.autoPermissionDetail}
|
||||||
>
|
>
|
||||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||||
<ToolContent>
|
<ToolContent>
|
||||||
|
|
@ -626,7 +636,7 @@ export function ChatSidebar({
|
||||||
<>
|
<>
|
||||||
{groupConversationItems(
|
{groupConversationItems(
|
||||||
tabState.conversation,
|
tabState.conversation,
|
||||||
(id) => !!tabState.allPermissionRequests.get(id)
|
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
|
||||||
).map((item) => {
|
).map((item) => {
|
||||||
if (isToolGroup(item)) {
|
if (isToolGroup(item)) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -638,22 +648,43 @@ export function ChatSidebar({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const rendered = renderConversationItem(item, tab.id)
|
const autoDecision = isToolCall(item)
|
||||||
if (isToolCall(item) && onPermissionResponse) {
|
? tabState.autoPermissionDecisions.get(item.id)
|
||||||
|
: undefined
|
||||||
|
const rendered = renderConversationItem(
|
||||||
|
item,
|
||||||
|
tab.id,
|
||||||
|
autoDecision?.decision === 'allow'
|
||||||
|
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
if (isToolCall(item)) {
|
||||||
|
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
|
||||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||||
if (permRequest) {
|
if (deniedAutoDecision || (permRequest && onPermissionResponse)) {
|
||||||
const response = tabState.permissionResponses.get(item.id) || null
|
const response = tabState.permissionResponses.get(item.id) || null
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
<PermissionRequest
|
{deniedAutoDecision && (
|
||||||
toolCall={permRequest.toolCall}
|
<AutoPermissionDecision
|
||||||
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
toolCall={deniedAutoDecision.toolCall}
|
||||||
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
permission={deniedAutoDecision.permission}
|
||||||
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
decision={deniedAutoDecision.decision}
|
||||||
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
reason={deniedAutoDecision.reason}
|
||||||
isProcessing={isActive && isProcessing}
|
/>
|
||||||
response={response}
|
)}
|
||||||
/>
|
{permRequest && onPermissionResponse && (
|
||||||
|
<PermissionRequest
|
||||||
|
toolCall={permRequest.toolCall}
|
||||||
|
permission={permRequest.permission}
|
||||||
|
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||||
|
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||||
|
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||||
|
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||||
|
isProcessing={isActive && isProcessing}
|
||||||
|
response={response}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{rendered}
|
{rendered}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -277,17 +277,27 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||||
"openai-compatible": "http://localhost:1234/v1",
|
"openai-compatible": "http://localhost:1234/v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProviderModelConfig = {
|
||||||
|
apiKey: string
|
||||||
|
baseURL: string
|
||||||
|
models: string[]
|
||||||
|
knowledgeGraphModel: string
|
||||||
|
meetingNotesModel: string
|
||||||
|
liveNoteAgentModel: string
|
||||||
|
autoPermissionDecisionModel: string
|
||||||
|
}
|
||||||
|
|
||||||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
||||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
})
|
})
|
||||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||||
const [modelsLoading, setModelsLoading] = useState(false)
|
const [modelsLoading, setModelsLoading] = useState(false)
|
||||||
|
|
@ -313,7 +323,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||||
|
|
||||||
const updateConfig = useCallback(
|
const updateConfig = useCallback(
|
||||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { ...prev[prov], ...updates },
|
[prov]: { ...prev[prov], ...updates },
|
||||||
|
|
@ -388,6 +398,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||||
meetingNotesModel: e.meetingNotesModel || "",
|
meetingNotesModel: e.meetingNotesModel || "",
|
||||||
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
||||||
|
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -406,6 +417,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||||
meetingNotesModel: parsed.meetingNotesModel || "",
|
meetingNotesModel: parsed.meetingNotesModel || "",
|
||||||
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
||||||
|
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|
@ -481,6 +493,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
||||||
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
||||||
|
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
|
||||||
}
|
}
|
||||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -515,6 +528,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||||
|
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
||||||
})
|
})
|
||||||
setDefaultProvider(prov)
|
setDefaultProvider(prov)
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
|
|
@ -546,6 +560,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
||||||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
||||||
|
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
|
||||||
}
|
}
|
||||||
await window.ipc.invoke("workspace:writeFile", {
|
await window.ipc.invoke("workspace:writeFile", {
|
||||||
path: "config/models.json",
|
path: "config/models.json",
|
||||||
|
|
@ -553,7 +568,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
})
|
})
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
}))
|
}))
|
||||||
setTestState({ status: "idle" })
|
setTestState({ status: "idle" })
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
|
|
@ -811,6 +826,40 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-permission model */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : showModelInput ? (
|
||||||
|
<Input
|
||||||
|
value={activeConfig.autoPermissionDecisionModel}
|
||||||
|
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
|
||||||
|
placeholder={primaryModel || "Enter model"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={activeConfig.autoPermissionDecisionModel || "__same__"}
|
||||||
|
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||||
|
{modelsForProvider.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.name || m.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ToolUIPart } from 'ai'
|
import type { ToolUIPart } from 'ai'
|
||||||
import z from 'zod'
|
import z from 'zod'
|
||||||
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||||
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
|
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
|
||||||
|
|
||||||
export interface MessageAttachment {
|
export interface MessageAttachment {
|
||||||
|
|
@ -46,6 +46,7 @@ export type ChatTabViewState = {
|
||||||
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||||
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||||
permissionResponses: Map<string, PermissionResponse>
|
permissionResponses: Map<string, PermissionResponse>
|
||||||
|
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatViewportAnchorState = {
|
export type ChatViewportAnchorState = {
|
||||||
|
|
@ -60,6 +61,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
||||||
pendingAskHumanRequests: new Map(),
|
pendingAskHumanRequests: new Map(),
|
||||||
allPermissionRequests: new Map(),
|
allPermissionRequests: new Map(),
|
||||||
permissionResponses: new Map(),
|
permissionResponses: new Map(),
|
||||||
|
autoPermissionDecisions: new Map(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
|
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||||
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
|
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
|
||||||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||||
|
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
|
||||||
|
|
||||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||||
|
|
||||||
|
|
@ -901,6 +902,7 @@ export class AgentState {
|
||||||
agentName: string | null = null;
|
agentName: string | null = null;
|
||||||
runModel: string | null = null;
|
runModel: string | null = null;
|
||||||
runProvider: string | null = null;
|
runProvider: string | null = null;
|
||||||
|
permissionMode: "manual" | "auto" = "manual";
|
||||||
runUseCase: UseCase | null = null;
|
runUseCase: UseCase | null = null;
|
||||||
runSubUseCase: string | null = null;
|
runSubUseCase: string | null = null;
|
||||||
messages: z.infer<typeof MessageList> = [];
|
messages: z.infer<typeof MessageList> = [];
|
||||||
|
|
@ -912,6 +914,8 @@ export class AgentState {
|
||||||
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
||||||
allowedToolCallIds: Record<string, true> = {};
|
allowedToolCallIds: Record<string, true> = {};
|
||||||
deniedToolCallIds: Record<string, true> = {};
|
deniedToolCallIds: Record<string, true> = {};
|
||||||
|
autoAllowedToolCalls: Record<string, { reason: string }> = {};
|
||||||
|
autoDeniedToolCalls: Record<string, { reason: string }> = {};
|
||||||
sessionAllowedCommands: Set<string> = new Set();
|
sessionAllowedCommands: Set<string> = new Set();
|
||||||
sessionAllowedFileAccess: FileAccessGrant[] = [];
|
sessionAllowedFileAccess: FileAccessGrant[] = [];
|
||||||
|
|
||||||
|
|
@ -1019,6 +1023,7 @@ export class AgentState {
|
||||||
this.agentName = event.agentName;
|
this.agentName = event.agentName;
|
||||||
this.runModel = event.model;
|
this.runModel = event.model;
|
||||||
this.runProvider = event.provider;
|
this.runProvider = event.provider;
|
||||||
|
this.permissionMode = event.permissionMode ?? "manual";
|
||||||
this.runUseCase = event.useCase ?? null;
|
this.runUseCase = event.useCase ?? null;
|
||||||
this.runSubUseCase = event.subUseCase ?? null;
|
this.runSubUseCase = event.subUseCase ?? null;
|
||||||
break;
|
break;
|
||||||
|
|
@ -1031,6 +1036,7 @@ export class AgentState {
|
||||||
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||||
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
||||||
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
||||||
|
this.subflowStates[event.toolCallId].permissionMode = this.permissionMode;
|
||||||
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
|
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
|
||||||
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
||||||
break;
|
break;
|
||||||
|
|
@ -1081,10 +1087,22 @@ export class AgentState {
|
||||||
break;
|
break;
|
||||||
case "deny":
|
case "deny":
|
||||||
this.deniedToolCallIds[event.toolCallId] = true;
|
this.deniedToolCallIds[event.toolCallId] = true;
|
||||||
|
delete this.autoDeniedToolCalls[event.toolCallId];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
delete this.pendingToolPermissionRequests[event.toolCallId];
|
delete this.pendingToolPermissionRequests[event.toolCallId];
|
||||||
break;
|
break;
|
||||||
|
case "tool-permission-auto-decision":
|
||||||
|
switch (event.decision) {
|
||||||
|
case "allow":
|
||||||
|
this.allowedToolCallIds[event.toolCallId] = true;
|
||||||
|
this.autoAllowedToolCalls[event.toolCallId] = { reason: event.reason };
|
||||||
|
break;
|
||||||
|
case "deny":
|
||||||
|
this.autoDeniedToolCalls[event.toolCallId] = { reason: event.reason };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "ask-human-request":
|
case "ask-human-request":
|
||||||
this.pendingAskHumanRequests[event.toolCallId] = event;
|
this.pendingAskHumanRequests[event.toolCallId] = event;
|
||||||
break;
|
break;
|
||||||
|
|
@ -1190,13 +1208,19 @@ export async function* streamAgent({
|
||||||
// if tool has been denied, deny
|
// if tool has been denied, deny
|
||||||
if (state.deniedToolCallIds[toolCallId]) {
|
if (state.deniedToolCallIds[toolCallId]) {
|
||||||
_logger.log('returning denied tool message, reason: tool has been denied');
|
_logger.log('returning denied tool message, reason: tool has been denied');
|
||||||
|
const autoDenied = state.autoDeniedToolCalls[toolCallId];
|
||||||
yield* processEvent({
|
yield* processEvent({
|
||||||
runId,
|
runId,
|
||||||
messageId: await idGenerator.next(),
|
messageId: await idGenerator.next(),
|
||||||
type: "message",
|
type: "message",
|
||||||
message: {
|
message: {
|
||||||
role: "tool",
|
role: "tool",
|
||||||
content: "Unable to execute this tool: Permission was denied.",
|
content: autoDenied
|
||||||
|
? JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: `Auto-permission denied: ${autoDenied.reason}`,
|
||||||
|
})
|
||||||
|
: "Unable to execute this tool: Permission was denied.",
|
||||||
toolCallId: toolCallId,
|
toolCallId: toolCallId,
|
||||||
toolName: toolCall.toolName,
|
toolName: toolCall.toolName,
|
||||||
},
|
},
|
||||||
|
|
@ -1493,6 +1517,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
||||||
|
|
||||||
// if there were any ask-human calls, emit those events
|
// if there were any ask-human calls, emit those events
|
||||||
if (message.content instanceof Array) {
|
if (message.content instanceof Array) {
|
||||||
|
const permissionCandidates: AutoPermissionCandidate[] = [];
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
const underlyingTool = agent.tools![part.toolName];
|
const underlyingTool = agent.tools![part.toolName];
|
||||||
|
|
@ -1518,14 +1543,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
||||||
state.sessionAllowedFileAccess,
|
state.sessionAllowedFileAccess,
|
||||||
);
|
);
|
||||||
if (permission) {
|
if (permission) {
|
||||||
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
|
permissionCandidates.push({ toolCall: part, permission });
|
||||||
yield* processEvent({
|
|
||||||
runId,
|
|
||||||
type: "tool-permission-request",
|
|
||||||
toolCall: part,
|
|
||||||
permission,
|
|
||||||
subflow: [],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
||||||
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
|
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
|
||||||
|
|
@ -1549,6 +1567,87 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (permissionCandidates.length > 0) {
|
||||||
|
if (state.permissionMode === "auto") {
|
||||||
|
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
|
||||||
|
try {
|
||||||
|
const decisions = await classifyToolPermissions({
|
||||||
|
runId,
|
||||||
|
agentName: state.agentName,
|
||||||
|
messages: convertFromMessages(state.messages),
|
||||||
|
candidates: permissionCandidates,
|
||||||
|
useCase: state.runUseCase ?? "copilot_chat",
|
||||||
|
subUseCase: state.runSubUseCase,
|
||||||
|
});
|
||||||
|
decisionsByToolCallId = new Map(decisions.map((decision) => [
|
||||||
|
decision.toolCallId,
|
||||||
|
{ decision: decision.decision, reason: decision.reason },
|
||||||
|
]));
|
||||||
|
} catch (error) {
|
||||||
|
loopLogger.log(
|
||||||
|
'auto-permission classifier failed:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of permissionCandidates) {
|
||||||
|
const decision = decisionsByToolCallId.get(candidate.toolCall.toolCallId);
|
||||||
|
if (!decision) {
|
||||||
|
loopLogger.log('auto-permission missing decision, falling back to prompt:', candidate.toolCall.toolCallId);
|
||||||
|
yield* processEvent({
|
||||||
|
runId,
|
||||||
|
type: "tool-permission-request",
|
||||||
|
toolCall: candidate.toolCall,
|
||||||
|
permission: candidate.permission,
|
||||||
|
subflow: [],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
loopLogger.log(
|
||||||
|
'emitting tool-permission-auto-decision, toolCallId:',
|
||||||
|
candidate.toolCall.toolCallId,
|
||||||
|
'decision:',
|
||||||
|
decision.decision,
|
||||||
|
);
|
||||||
|
yield* processEvent({
|
||||||
|
runId,
|
||||||
|
type: "tool-permission-auto-decision",
|
||||||
|
toolCallId: candidate.toolCall.toolCallId,
|
||||||
|
toolCall: candidate.toolCall,
|
||||||
|
permission: candidate.permission,
|
||||||
|
decision: decision.decision,
|
||||||
|
reason: decision.reason,
|
||||||
|
subflow: [],
|
||||||
|
});
|
||||||
|
if (decision.decision === "deny") {
|
||||||
|
loopLogger.log(
|
||||||
|
'auto-permission denied, falling back to prompt:',
|
||||||
|
candidate.toolCall.toolCallId,
|
||||||
|
);
|
||||||
|
yield* processEvent({
|
||||||
|
runId,
|
||||||
|
type: "tool-permission-request",
|
||||||
|
toolCall: candidate.toolCall,
|
||||||
|
permission: candidate.permission,
|
||||||
|
subflow: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const candidate of permissionCandidates) {
|
||||||
|
loopLogger.log('emitting tool-permission-request, toolCallId:', candidate.toolCall.toolCallId);
|
||||||
|
yield* processEvent({
|
||||||
|
runId,
|
||||||
|
type: "tool-permission-request",
|
||||||
|
toolCall: candidate.toolCall,
|
||||||
|
permission: candidate.permission,
|
||||||
|
subflow: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
|
||||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||||
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
|
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
|
||||||
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
|
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
|
||||||
|
const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single source of truth for "what model+provider should we use when
|
* The single source of truth for "what model+provider should we use when
|
||||||
|
|
@ -76,6 +77,17 @@ export async function getLiveNoteAgentModel(): Promise<string> {
|
||||||
return cfg.liveNoteAgentModel ?? cfg.model;
|
return cfg.liveNoteAgentModel ?? cfg.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model used by the auto-permission classifier.
|
||||||
|
* Signed-in: curated default. BYOK: user override
|
||||||
|
* (`autoPermissionDecisionModel`) or assistant model.
|
||||||
|
*/
|
||||||
|
export async function getAutoPermissionDecisionModel(): Promise<string> {
|
||||||
|
if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL;
|
||||||
|
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||||
|
return cfg.autoPermissionDecisionModel ?? cfg.model;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model used by the meeting-notes summarizer. No special signed-in default —
|
* Model used by the meeting-notes summarizer. No special signed-in default —
|
||||||
* historically meetings used the assistant model. BYOK: user override
|
* historically meetings used the assistant model. BYOK: user override
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
||||||
knowledgeGraphModel: config.knowledgeGraphModel,
|
knowledgeGraphModel: config.knowledgeGraphModel,
|
||||||
meetingNotesModel: config.meetingNotesModel,
|
meetingNotesModel: config.meetingNotesModel,
|
||||||
liveNoteAgentModel: config.liveNoteAgentModel,
|
liveNoteAgentModel: config.liveNoteAgentModel,
|
||||||
|
autoPermissionDecisionModel: config.autoPermissionDecisionModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
const toWrite = { ...config, providers: existingProviders };
|
const toWrite = { ...config, providers: existingProviders };
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export type CreateRunRepoOptions = {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
model: string;
|
model: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
permissionMode: "manual" | "auto";
|
||||||
useCase: z.infer<typeof UseCase>;
|
useCase: z.infer<typeof UseCase>;
|
||||||
subUseCase?: string;
|
subUseCase?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -204,6 +205,7 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
agentName: options.agentId,
|
agentName: options.agentId,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
|
permissionMode: options.permissionMode,
|
||||||
useCase: options.useCase,
|
useCase: options.useCase,
|
||||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||||
subflow: [],
|
subflow: [],
|
||||||
|
|
@ -216,6 +218,7 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
agentId: options.agentId,
|
agentId: options.agentId,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
|
permissionMode: options.permissionMode,
|
||||||
useCase: options.useCase,
|
useCase: options.useCase,
|
||||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||||
log: [start],
|
log: [start],
|
||||||
|
|
@ -251,6 +254,7 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
agentId: start.agentName,
|
agentId: start.agentName,
|
||||||
model: start.model,
|
model: start.model,
|
||||||
provider: start.provider,
|
provider: start.provider,
|
||||||
|
permissionMode: start.permissionMode ?? "manual",
|
||||||
...(start.useCase ? { useCase: start.useCase } : {}),
|
...(start.useCase ? { useCase: start.useCase } : {}),
|
||||||
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
||||||
log: events,
|
log: events,
|
||||||
|
|
@ -320,4 +324,4 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
await fsp.unlink(runLogPath(id));
|
await fsp.unlink(runLogPath(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
||||||
agentId: opts.agentId,
|
agentId: opts.agentId,
|
||||||
model,
|
model,
|
||||||
provider,
|
provider,
|
||||||
|
permissionMode: opts.permissionMode ?? "manual",
|
||||||
useCase,
|
useCase,
|
||||||
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
112
apps/x/packages/core/src/security/auto-permission-classifier.ts
Normal file
112
apps/x/packages/core/src/security/auto-permission-classifier.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { generateObject, type ModelMessage } from "ai";
|
||||||
|
import z from "zod";
|
||||||
|
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||||
|
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||||
|
import { captureLlmUsage } from "../analytics/usage.js";
|
||||||
|
import { withUseCase, type UseCase } from "../analytics/use_case.js";
|
||||||
|
import { getAutoPermissionDecisionModel, getDefaultModelAndProvider, resolveProviderConfig } from "../models/defaults.js";
|
||||||
|
import { createProvider } from "../models/models.js";
|
||||||
|
|
||||||
|
const DecisionSchema = z.object({
|
||||||
|
decisions: z.array(z.object({
|
||||||
|
toolCallId: z.string(),
|
||||||
|
decision: z.enum(["allow", "deny"]),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AutoPermissionCandidate = {
|
||||||
|
toolCall: z.infer<typeof ToolCallPart>;
|
||||||
|
permission: z.infer<typeof ToolPermissionMetadata>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoPermissionDecision = {
|
||||||
|
toolCallId: string;
|
||||||
|
decision: "allow" | "deny";
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You decide whether a personal productivity app may run tool calls without interrupting the user.
|
||||||
|
|
||||||
|
You only receive tool calls that already require permission under deterministic rules.
|
||||||
|
|
||||||
|
Allow a tool call only when it is clearly consistent with the user's request and low risk.
|
||||||
|
Deny tool calls that are destructive, credential-sensitive, privacy-sensitive, broad in scope, likely irreversible, or not clearly requested.
|
||||||
|
|
||||||
|
Command examples to deny unless explicitly requested: deleting data, force pushing, deploying, running migrations, changing permissions, reading secrets, exfiltrating tokens, or modifying files outside the user's workspace.
|
||||||
|
File examples to deny unless explicitly requested: deleting paths, writing outside the workspace, reading secrets or credentials, or broad access to private directories.
|
||||||
|
|
||||||
|
Return one decision for every toolCallId. Use the exact toolCallId values provided.`;
|
||||||
|
|
||||||
|
function compact(value: unknown, max = 8_000): string {
|
||||||
|
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||||
|
if (text.length <= max) return text;
|
||||||
|
return `${text.slice(0, max)}\n...<truncated>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentContext(messages: ModelMessage[]): unknown[] {
|
||||||
|
return messages.slice(-8).map((message) => {
|
||||||
|
if (typeof message.content === "string") {
|
||||||
|
return { role: message.role, content: compact(message.content, 2_000) };
|
||||||
|
}
|
||||||
|
return { role: message.role, content: compact(message.content, 3_000) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(input: {
|
||||||
|
agentName: string | null;
|
||||||
|
messages: ModelMessage[];
|
||||||
|
candidates: AutoPermissionCandidate[];
|
||||||
|
}) {
|
||||||
|
return compact({
|
||||||
|
agentName: input.agentName,
|
||||||
|
recentConversation: recentContext(input.messages),
|
||||||
|
toolCalls: input.candidates.map(({ toolCall, permission }) => ({
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
toolName: toolCall.toolName,
|
||||||
|
arguments: toolCall.arguments,
|
||||||
|
permission,
|
||||||
|
})),
|
||||||
|
}, 24_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function classifyToolPermissions(input: {
|
||||||
|
runId: string;
|
||||||
|
agentName: string | null;
|
||||||
|
messages: ModelMessage[];
|
||||||
|
candidates: AutoPermissionCandidate[];
|
||||||
|
useCase: UseCase;
|
||||||
|
subUseCase?: string | null;
|
||||||
|
}): Promise<AutoPermissionDecision[]> {
|
||||||
|
if (input.candidates.length === 0) return [];
|
||||||
|
|
||||||
|
const modelId = await getAutoPermissionDecisionModel();
|
||||||
|
const { provider: providerName } = await getDefaultModelAndProvider();
|
||||||
|
const providerConfig = await resolveProviderConfig(providerName);
|
||||||
|
const model = createProvider(providerConfig).languageModel(modelId);
|
||||||
|
|
||||||
|
const result = await withUseCase(
|
||||||
|
{
|
||||||
|
useCase: input.useCase,
|
||||||
|
subUseCase: "auto_permission_classifier",
|
||||||
|
...(input.agentName ? { agentName: input.agentName } : {}),
|
||||||
|
},
|
||||||
|
() => generateObject({
|
||||||
|
model,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
prompt: buildPrompt(input),
|
||||||
|
schema: DecisionSchema,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
captureLlmUsage({
|
||||||
|
useCase: input.useCase,
|
||||||
|
subUseCase: "auto_permission_classifier",
|
||||||
|
model: modelId,
|
||||||
|
provider: providerName,
|
||||||
|
usage: result.usage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedIds = new Set(input.candidates.map((candidate) => candidate.toolCall.toolCallId));
|
||||||
|
return result.object.decisions.filter((decision) => allowedIds.has(decision.toolCallId));
|
||||||
|
}
|
||||||
|
|
@ -17,10 +17,15 @@ export const LlmModelConfig = z.object({
|
||||||
headers: z.record(z.string(), z.string()).optional(),
|
headers: z.record(z.string(), z.string()).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
models: z.array(z.string()).optional(),
|
models: z.array(z.string()).optional(),
|
||||||
|
knowledgeGraphModel: z.string().optional(),
|
||||||
|
meetingNotesModel: z.string().optional(),
|
||||||
|
liveNoteAgentModel: z.string().optional(),
|
||||||
|
autoPermissionDecisionModel: z.string().optional(),
|
||||||
})).optional(),
|
})).optional(),
|
||||||
// Per-category model overrides (BYOK only — signed-in users always get
|
// Per-category model overrides (BYOK only — signed-in users always get
|
||||||
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
|
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
|
||||||
knowledgeGraphModel: z.string().optional(),
|
knowledgeGraphModel: z.string().optional(),
|
||||||
meetingNotesModel: z.string().optional(),
|
meetingNotesModel: z.string().optional(),
|
||||||
liveNoteAgentModel: z.string().optional(),
|
liveNoteAgentModel: z.string().optional(),
|
||||||
|
autoPermissionDecisionModel: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export const StartEvent = BaseRunEvent.extend({
|
||||||
agentName: z.string(),
|
agentName: z.string(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
|
permissionMode: z.enum(["manual", "auto"]).optional(),
|
||||||
// useCase/subUseCase tag the run for analytics. Optional on read so legacy
|
// useCase/subUseCase tag the run for analytics. Optional on read so legacy
|
||||||
// run files written before these fields existed still parse cleanly.
|
// run files written before these fields existed still parse cleanly.
|
||||||
useCase: z.enum([
|
useCase: z.enum([
|
||||||
|
|
@ -110,6 +111,15 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({
|
||||||
scope: z.enum(["once", "session", "always"]).optional(),
|
scope: z.enum(["once", "session", "always"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({
|
||||||
|
type: z.literal("tool-permission-auto-decision"),
|
||||||
|
toolCallId: z.string(),
|
||||||
|
toolCall: ToolCallPart,
|
||||||
|
permission: ToolPermissionMetadata.optional(),
|
||||||
|
decision: z.enum(["allow", "deny"]),
|
||||||
|
reason: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const RunErrorEvent = BaseRunEvent.extend({
|
export const RunErrorEvent = BaseRunEvent.extend({
|
||||||
type: z.literal("error"),
|
type: z.literal("error"),
|
||||||
error: z.string(),
|
error: z.string(),
|
||||||
|
|
@ -134,6 +144,7 @@ export const RunEvent = z.union([
|
||||||
AskHumanResponseEvent,
|
AskHumanResponseEvent,
|
||||||
ToolPermissionRequestEvent,
|
ToolPermissionRequestEvent,
|
||||||
ToolPermissionResponseEvent,
|
ToolPermissionResponseEvent,
|
||||||
|
ToolPermissionAutoDecisionEvent,
|
||||||
RunErrorEvent,
|
RunErrorEvent,
|
||||||
RunStoppedEvent,
|
RunStoppedEvent,
|
||||||
]);
|
]);
|
||||||
|
|
@ -166,6 +177,7 @@ export const Run = z.object({
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
|
permissionMode: z.enum(["manual", "auto"]).optional(),
|
||||||
useCase: UseCase.optional(),
|
useCase: UseCase.optional(),
|
||||||
subUseCase: z.string().optional(),
|
subUseCase: z.string().optional(),
|
||||||
log: z.array(RunEvent),
|
log: z.array(RunEvent),
|
||||||
|
|
@ -185,6 +197,7 @@ export const CreateRunOptions = z.object({
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
provider: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
|
permissionMode: z.enum(["manual", "auto"]).optional(),
|
||||||
useCase: UseCase.optional(),
|
useCase: UseCase.optional(),
|
||||||
subUseCase: z.string().optional(),
|
subUseCase: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue