+ {#if showContextPicker && !disabled}
+
+ {#snippet trigger()}
+
+ @
+
+ {/snippet}
+ {#snippet content({ close })}
+ {#if aiChatManager.mode === AIMode.APP}
+ {
+ void aiChatInput?.addContextToSelection(element)
+ close()
+ }}
+ />
+ {:else}
+ {
+ void aiChatInput?.addContextToSelection(element)
+ close()
+ }}
+ onSelectWorkspaceItem={(element) => {
+ void aiChatInput?.addContextToSelection(element)
+ close()
+ }}
+ />
+ {/if}
+ {/snippet}
+
+ {/if}
+ {#if showAutonomyModeSelector}
+
+
+ {#snippet trigger()}
+
+
+
{autonomyModeLabel(
+ effectiveAutonomyMode,
+ availableAutonomyModeOptions
+ )}
+
+
+
+
+ {/snippet}
+ {#snippet content({ close })}
+
+ {#each availableAutonomyModeOptions as option (option.mode)}
+
+ {/each}
+
+ {/snippet}
+
+
+ {/if}
+ {#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
+
+
+ {#snippet text()}
+
+
+ {aiChatManager.autoAcceptEditsAvailable
+ ? 'Yolo auto-accepts edits and tool usage.'
+ : 'Yolo auto-accepts tool usage.'}
+
+
+ {aiChatManager.autoAcceptEditsAvailable
+ ? 'This can result in edits being applied or tools being called without user confirmation.'
+ : 'This can result in tools being called without user confirmation.'}
+
+ {#if yoloBypassedTools.length > 0}
+
Bypassed in current mode:
+
+ {#each visibleYoloBypassedTools as tool (tool.name)}
+ - {tool.label}
+ {/each}
+
+ {#if hiddenYoloBypassedToolCount > 0}
+
+ {hiddenYoloBypassedToolCount} more
+ {/if}
+ {:else}
+
No tools in the current mode require confirmation.
+ {/if}
+
+ {/snippet}
+
+ {/if}
+ {#if aiChatManager.mode === AIMode.SCRIPT && hasDiff}
+
+ {/if}
+
diff --git a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte
index 8e57dde1fe..da1a1e26af 100644
--- a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte
+++ b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte
@@ -213,7 +213,7 @@
try {
const reply = await aiChatManager.sendInlineRequest(instructions, selectedCode, selection)
if (reply) {
- aiChatManager.scriptEditorApplyCode?.(reply)
+ await aiChatManager.applyScriptEditorCode(reply)
}
} catch (error) {
console.error('Inline AI request failed:', error)
diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts
index 8b56edcc43..93145e3b1b 100644
--- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts
+++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts
@@ -43,6 +43,7 @@ import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState
import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types'
import { untrack } from 'svelte'
import { get } from 'svelte/store'
+import { BROWSER } from 'esm-env'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
@@ -66,6 +67,8 @@ import { isGlobalAiEnabled } from './global/gate'
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
const MAX_TOKENS_HARD_LIMIT = 5000
+const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
+const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
export enum AIMode {
SCRIPT = 'script',
@@ -77,12 +80,38 @@ export enum AIMode {
ASK = 'ask'
}
+export enum AIAutonomyMode {
+ DEFAULT = 'default',
+ ACCEPT_EDIT = 'acceptedit',
+ YOLO = 'yolo'
+}
+
const ALL_AI_MODES = Object.values(AIMode)
+const ALL_AI_AUTONOMY_MODES = Object.values(AIAutonomyMode)
+const AUTO_ACCEPT_EDIT_MODES = new Set
([AIMode.SCRIPT, AIMode.FLOW])
+const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set([
+ AIMode.SCRIPT,
+ AIMode.FLOW,
+ AIMode.APP,
+ AIMode.GLOBAL
+])
export function isAIMode(mode: unknown): mode is AIMode {
return ALL_AI_MODES.includes(mode as AIMode)
}
+export function isAIAutonomyMode(mode: unknown): mode is AIAutonomyMode {
+ return ALL_AI_AUTONOMY_MODES.includes(mode as AIAutonomyMode)
+}
+
+export function supportsAutoAcceptEdits(mode: AIMode): boolean {
+ return AUTO_ACCEPT_EDIT_MODES.has(mode)
+}
+
+export function supportsAutoAcceptToolConfirmations(mode: AIMode): boolean {
+ return AUTO_ACCEPT_TOOL_CONFIRMATION_MODES.has(mode)
+}
+
export function isAIModeVisible(mode: AIMode): boolean {
return mode !== AIMode.GLOBAL || isGlobalAiEnabled()
}
@@ -95,6 +124,26 @@ function isWorkspacePath(path: string | undefined): path is string {
return path?.startsWith('f/') === true || path?.startsWith('u/') === true
}
+function getPersistedAutonomyMode(): AIAutonomyMode {
+ if (!BROWSER || typeof localStorage === 'undefined') {
+ return AIAutonomyMode.DEFAULT
+ }
+ const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY)
+ if (isAIAutonomyMode(persistedMode)) {
+ return persistedMode
+ }
+ return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true'
+ ? AIAutonomyMode.YOLO
+ : AIAutonomyMode.DEFAULT
+}
+
+function persistAutonomyMode(mode: AIAutonomyMode) {
+ if (!BROWSER || typeof localStorage === 'undefined') {
+ return
+ }
+ localStorage.setItem(AI_AUTONOMY_MODE_STORAGE_KEY, mode)
+}
+
export class AIChatManager {
contextManager = new ContextManager()
historyManager = new HistoryManager()
@@ -112,6 +161,17 @@ export class AIChatManager {
currentReply = $state('')
displayMessages = $state([])
messages = $state([])
+ autonomyMode = $state(getPersistedAutonomyMode())
+ autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
+ autoAcceptEditsActive = $derived(
+ this.autoAcceptEditsAvailable &&
+ (this.autonomyMode === AIAutonomyMode.ACCEPT_EDIT ||
+ this.autonomyMode === AIAutonomyMode.YOLO)
+ )
+ autoAcceptToolConfirmationsAvailable = $derived(supportsAutoAcceptToolConfirmations(this.mode))
+ autoAcceptToolConfirmationsActive = $derived(
+ this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable
+ )
#automaticScroll = $state(true)
systemMessage = $state({
role: 'system',
@@ -122,9 +182,9 @@ export class AIChatManager {
scriptEditorOptions = $state(undefined)
flowOptions = $state(undefined)
- scriptEditorApplyCode = $state<((code: string, opts?: ReviewChangesOpts) => void) | undefined>(
- undefined
- )
+ scriptEditorApplyCode = $state<
+ ((code: string, opts?: ReviewChangesOpts) => void | Promise) | undefined
+ >(undefined)
scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined)
scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined)
flowAiChatHelpers = $state(undefined)
@@ -141,7 +201,7 @@ export class AIChatManager {
/** Cached datatables for app context (fetched asynchronously) */
cachedDatatables = $state([])
- private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
+ private confirmationCallbacks = new Map void>()
private userQuestionCallbacks = new Map void>()
private appDatatablesRefreshTimeout: ReturnType | undefined = undefined
@@ -215,20 +275,65 @@ export class AIChatManager {
// Request confirmation from user for a tool call
requestConfirmation = (toolId: string): Promise => {
+ if (this.autoAcceptToolConfirmationsActive) {
+ return Promise.resolve(true)
+ }
+
return new Promise((resolve) => {
- // Store the callback for this specific tool
- this.confirmationCallback = resolve
+ this.confirmationCallbacks.set(toolId, resolve)
})
}
// Handle confirmation response for a specific tool
handleToolConfirmation = (toolId: string, confirmed: boolean) => {
- if (this.confirmationCallback) {
- this.confirmationCallback(confirmed)
- this.confirmationCallback = undefined
+ const confirmationCallback = this.confirmationCallbacks.get(toolId)
+ if (confirmationCallback) {
+ confirmationCallback(confirmed)
+ this.confirmationCallbacks.delete(toolId)
}
}
+ private acceptPendingToolConfirmations = () => {
+ for (const confirmationCallback of this.confirmationCallbacks.values()) {
+ confirmationCallback(true)
+ }
+ this.confirmationCallbacks.clear()
+ }
+
+ private acceptPendingFlowEdits = (flowHelpers = this.flowAiChatHelpers) => {
+ if (flowHelpers?.hasPendingChanges()) {
+ flowHelpers.acceptAllModuleActions()
+ }
+ }
+
+ setAutonomyMode = (mode: AIAutonomyMode) => {
+ this.autonomyMode = mode
+ persistAutonomyMode(mode)
+
+ if (this.autoAcceptToolConfirmationsActive) {
+ this.acceptPendingToolConfirmations()
+ }
+ if (this.autoAcceptEditsActive) {
+ this.acceptPendingFlowEdits()
+ }
+ }
+
+ setAutoAcceptToolConfirmations = (enabled: boolean) => {
+ this.setAutonomyMode(enabled ? AIAutonomyMode.YOLO : AIAutonomyMode.DEFAULT)
+ }
+
+ applyScriptEditorCode = async (code: string, opts?: ReviewChangesOpts) => {
+ if (this.autoAcceptEditsActive && opts?.mode === 'revert') {
+ return
+ }
+
+ const effectiveOpts =
+ this.autoAcceptEditsActive && (opts?.mode ?? 'apply') === 'apply'
+ ? ({ ...opts, mode: 'apply', applyAll: true } satisfies ReviewChangesOpts)
+ : opts
+ await this.scriptEditorApplyCode?.(code, effectiveOpts)
+ }
+
requestUserQuestion = (
toolId: string,
_question: { question: string; choices: string[] }
@@ -346,7 +451,7 @@ export class AIChatManager {
},
getWorkspaceMutationTarget: this.getScriptWorkspaceMutationTarget,
applyCode: (code: string, opts?: ReviewChangesOpts) => {
- this.scriptEditorApplyCode?.(code, opts)
+ return this.applyScriptEditorCode(code, opts)
},
getLintErrors: () => {
if (this.scriptEditorGetLintErrors) {
@@ -874,6 +979,7 @@ export class AIChatManager {
}
},
requestConfirmation: this.requestConfirmation,
+ shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive,
requestUserQuestion: this.requestUserQuestion
}
}
@@ -886,6 +992,9 @@ export class AIChatManager {
...params
})
this.messages = [...this.messages, ...(addedMessages ?? [])]
+ if (this.autoAcceptEditsActive) {
+ this.acceptPendingFlowEdits()
+ }
await this.historyManager.saveChat(this.displayMessages, this.messages)
} catch (err) {
console.error(err)
@@ -901,10 +1010,10 @@ export class AIChatManager {
}
cancel = (reason?: string) => {
- if (this.confirmationCallback) {
- this.confirmationCallback(false)
- this.confirmationCallback = undefined
+ for (const confirmationCallback of this.confirmationCallbacks.values()) {
+ confirmationCallback(false)
}
+ this.confirmationCallbacks.clear()
for (const resolveQuestion of this.userQuestionCallbacks.values()) {
resolveQuestion(undefined)
}
@@ -1060,10 +1169,10 @@ export class AIChatManager {
listenForCurrentEditorChanges = (currentEditor: CurrentEditor) => {
if (currentEditor && currentEditor.type === 'script') {
- this.scriptEditorApplyCode = (code) => {
+ this.scriptEditorApplyCode = async (code, opts) => {
if (currentEditor && currentEditor.type === 'script') {
currentEditor.hideDiffMode()
- currentEditor.editor.reviewAndApplyCode(code)
+ await currentEditor.editor.reviewAndApplyCode(code, opts)
}
}
this.scriptEditorShowDiffMode = () => {
@@ -1164,6 +1273,11 @@ export class AIChatManager {
setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => {
this.flowAiChatHelpers = flowHelpers
+ untrack(() => {
+ if (this.autoAcceptEditsActive) {
+ this.acceptPendingFlowEdits(flowHelpers)
+ }
+ })
return () => {
this.flowAiChatHelpers = undefined
diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts
new file mode 100644
index 0000000000..df2cba5575
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts
@@ -0,0 +1,155 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { FlowAIChatHelpers } from './flow/core'
+import type { CurrentEditor } from '$lib/components/flows/types'
+import type { ReviewChangesOpts } from './monaco-adapter'
+import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
+
+vi.mock('monaco-editor', () => ({
+ Selection: class Selection {}
+}))
+
+vi.mock('$lib/gen', () => ({
+ WorkspaceService: {},
+ ScriptService: {},
+ FlowService: {},
+ JobService: {}
+}))
+
+vi.mock('$lib/stores', () => ({
+ workspaceStore: { subscribe: () => () => undefined }
+}))
+
+vi.mock('$lib/toast', () => ({
+ sendUserToast: vi.fn()
+}))
+
+vi.mock('$lib/aiStore', () => ({
+ getCurrentModel: () => undefined,
+ tryGetCurrentModel: () => undefined,
+ getCombinedCustomPrompt: () => ''
+}))
+
+vi.mock('../lib', () => ({
+ getModelContextWindow: () => 128000,
+ workspaceAIClients: { subscribe: () => () => undefined }
+}))
+
+vi.mock('./api/apiTools', () => ({
+ loadApiTools: vi.fn()
+}))
+
+vi.mock('./chatLoop', () => ({
+ runChatLoop: vi.fn()
+}))
+
+vi.mock('./global/gate', () => ({
+ isGlobalAiEnabled: () => true
+}))
+
+function createFlowHelpers({
+ hasPendingChanges,
+ acceptAllModuleActions
+}: {
+ hasPendingChanges: () => boolean
+ acceptAllModuleActions: () => void
+}): FlowAIChatHelpers {
+ return {
+ getFlowAndSelectedId: vi.fn(),
+ getRootModules: vi.fn(),
+ inlineScriptSession: { get: vi.fn(), set: vi.fn(), clear: vi.fn() },
+ setSnapshot: vi.fn(),
+ revertToSnapshot: vi.fn(),
+ setCode: vi.fn(),
+ setFlowJson: vi.fn(),
+ getFlowInputsSchema: vi.fn(),
+ updateExprsToSet: vi.fn(),
+ acceptAllModuleActions,
+ rejectAllModuleActions: vi.fn(),
+ hasPendingChanges,
+ selectStep: vi.fn(),
+ testFlow: vi.fn(),
+ getLintErrors: vi.fn()
+ } as unknown as FlowAIChatHelpers
+}
+
+describe('AIChatManager autonomy mode', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ vi.clearAllMocks()
+ })
+
+ it('accepts pending flow edits when auto-accept is enabled from script mode', async () => {
+ const manager = new AIChatManager()
+ const acceptAllModuleActions = vi.fn()
+
+ manager.mode = AIMode.SCRIPT
+ manager.setFlowHelpers(
+ createFlowHelpers({
+ hasPendingChanges: () => true,
+ acceptAllModuleActions
+ })
+ )
+
+ manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT)
+
+ expect(acceptAllModuleActions).toHaveBeenCalledTimes(1)
+ })
+
+ it('accepts pending flow edits when helpers register while auto-accept is already enabled', async () => {
+ const manager = new AIChatManager()
+ const acceptAllModuleActions = vi.fn()
+
+ manager.mode = AIMode.SCRIPT
+ manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT)
+ manager.setFlowHelpers(
+ createFlowHelpers({
+ hasPendingChanges: () => true,
+ acceptAllModuleActions
+ })
+ )
+
+ expect(acceptAllModuleActions).toHaveBeenCalledTimes(1)
+ })
+
+ it('waits for flow step editor review before resolving applyScriptEditorCode', async () => {
+ const manager = new AIChatManager()
+ let finishReview: (() => void) | undefined
+ const reviewPromise = new Promise((resolve) => {
+ finishReview = resolve
+ })
+ const hideDiffMode = vi.fn()
+ const reviewAndApplyCode = vi.fn(() => reviewPromise)
+ const opts = { mode: 'apply' } satisfies ReviewChangesOpts
+
+ manager.listenForCurrentEditorChanges({
+ type: 'script',
+ stepId: 'step-a',
+ editor: {
+ reviewAndApplyCode,
+ getLintErrors: vi.fn()
+ },
+ showDiffMode: vi.fn(),
+ hideDiffMode,
+ diffMode: false,
+ lastDeployedCode: undefined
+ } as unknown as CurrentEditor)
+
+ let applied = false
+ const applyPromise = manager
+ .applyScriptEditorCode('export async function main() {}', opts)
+ .then(() => {
+ applied = true
+ })
+
+ await Promise.resolve()
+
+ expect(hideDiffMode).toHaveBeenCalledTimes(1)
+ expect(reviewAndApplyCode).toHaveBeenCalledWith('export async function main() {}', opts)
+ expect(applied).toBe(false)
+
+ finishReview?.()
+ await applyPromise
+
+ expect(applied).toBe(true)
+ })
+})
diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte
index d8ca5858ea..9a12a04548 100644
--- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte
+++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte
@@ -1,6 +1,6 @@
diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts
index d26f5cbb54..69b868ee50 100644
--- a/frontend/src/lib/components/copilot/chat/shared.test.ts
+++ b/frontend/src/lib/components/copilot/chat/shared.test.ts
@@ -247,6 +247,49 @@ describe('processToolCall', () => {
expect(result.content).toBe('ok')
})
+ it('auto-accepts required confirmations when yolo mode is active', async () => {
+ const { createToolDef, processToolCall } = await import('./shared')
+ const fn = vi.fn().mockResolvedValue('ok')
+ const requestConfirmation = vi.fn()
+ const setToolStatus = vi.fn()
+
+ const result = await processToolCall({
+ tools: [
+ {
+ def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'),
+ requiresConfirmation: true,
+ confirmationMessage: 'Create schedule',
+ fn
+ }
+ ],
+ toolCall: {
+ id: 'call_yolo',
+ type: 'function',
+ function: { name: 'create_schedule', arguments: '{}' }
+ },
+ helpers: {},
+ workspace: 'test-workspace',
+ toolCallbacks: {
+ setToolStatus,
+ removeToolStatus: vi.fn(),
+ requestConfirmation,
+ shouldAutoAcceptToolConfirmations: () => true
+ }
+ })
+
+ expect(requestConfirmation).not.toHaveBeenCalled()
+ expect(fn).toHaveBeenCalled()
+ expect(setToolStatus).toHaveBeenCalledWith(
+ 'call_yolo',
+ expect.objectContaining({
+ content: 'Create schedule',
+ isLoading: true,
+ needsConfirmation: false
+ })
+ )
+ expect(result.content).toBe('ok')
+ })
+
it('blocks workspace mutation tools for undeployed scripts and flows', async () => {
const { processToolCall } = await import('./shared')
const { createWorkspaceMutationTools } = await import('./workspaceTools')
diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts
index 0723cc3eb3..88ed20d664 100644
--- a/frontend/src/lib/components/copilot/chat/shared.ts
+++ b/frontend/src/lib/components/copilot/chat/shared.ts
@@ -582,10 +582,13 @@ export async function processToolCall({
}
// Check if tool requires confirmation
- const needsConfirmation = tool?.requiresConfirmation
+ const requiresConfirmation = tool?.requiresConfirmation === true
+ const autoAcceptConfirmation =
+ requiresConfirmation && toolCallbacks.shouldAutoAcceptToolConfirmations?.() === true
+ const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation
toolCallbacks.setToolStatus(toolCall.id, {
- ...(tool?.requiresConfirmation
+ ...(requiresConfirmation
? { content: tool.confirmationMessage ?? 'Waiting for confirmation...' }
: {}),
parameters: args,
@@ -695,6 +698,7 @@ export interface ToolCallbacks {
setToolStatus: (id: string, metadata?: Partial) => void
removeToolStatus: (id: string) => void
requestConfirmation?: (toolId: string) => Promise
+ shouldAutoAcceptToolConfirmations?: () => boolean
requestUserQuestion?: (
toolId: string,
question: UserQuestionDisplay