Files
windmill/frontend/src/lib/components/flows/content/AgentEditorHost.svelte
T
hugocasa f589d9e73b Merge branch 'agents-home-plus-menu' into agents-run-on-behalf-of
# Conflicts:
#	frontend/src/lib/components/flows/content/AgentEditorHost.svelte
2026-09-30 12:27:39 +02:00

740 lines
28 KiB
Svelte

<script lang="ts">
import { getContext, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import { MessageCircleOff, SlidersHorizontal } from 'lucide-svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import { Alert, Button } from '$lib/components/common'
import FlowChat from '../conversations/FlowChat.svelte'
import {
AGENT_CHAT_SCHEMA,
agentChatFlow,
agentChatGap,
agentChatPath
} from '../conversations/agentEditorChat'
import { runFlowPreview } from '../utils.svelte'
import { goto } from '$lib/navigation'
import { deepEqual } from 'fast-equals'
import type { Flow, FlowModule, InputTransform, Job, OpenFlow } from '$lib/gen'
import { JobService } from '$lib/gen'
import { emptySchema, type StateStore } from '$lib/utils'
import type { FlowInput } from '$lib/components/flows/types'
import type { FlowEditorContext, FlowInputEditorState, FlowPanelDetachContext } from '../types'
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
import { initFlowState, type FlowState } from '../flowState'
import { insertAgentTool } from '../flowStateUtils.svelte'
import { initHistory } from '$lib/history.svelte'
import { StepsInputArgs } from '../stepsInputArgs.svelte'
import { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
import { Splitpanes, Pane } from 'svelte-splitpanes'
import { Drawer, DrawerContent } from '$lib/components/common'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import ModulePreview from '$lib/components/ModulePreview.svelte'
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
import AiAgentStepInputs from './AiAgentStepInputs.svelte'
import AgentToolWrapper from './AgentToolWrapper.svelte'
import { getStepPropPicker } from '../previousResults'
import {
AGENT_BRAIN_KEYS,
flowLocalAgentSchema,
inputTransformsToAgentConfig,
type AIAgentConfig
} from '../agentResourceUtils'
import { agentArgsToTransforms } from '../linkedAgentDrafts'
import {
AGENT_EDITOR_RUN_INPUTS,
AGENT_TOOLS_ROW,
DEFAULT_AGENT_MEMORY,
keepsManagedMemory
} from '../agentFormFields'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import { useAgentDraft, type AgentRunAs } from '../agentDraft.svelte'
import { sendUserToast } from '$lib/toast'
interface Props {
/** The `ai_agent` resource being edited. */
path: string
workspace: string | undefined
enableAi?: boolean
/** The tool drilled into, if any. Owned by the caller so it can drive the breadcrumb. */
toolId?: string | undefined
onSelectTool?: (toolId: string | undefined) => void
/** Ran after a successful deploy, with the path actually written, which a rename can move. */
onSaved?: (path: string) => void | Promise<void>
/** The path was minted for a new agent, so a missing row starts an empty one. */
isNew?: boolean
/** The deployed agent to run rather than a draft to edit: the run pane leads, and the form
* beside it only shows the configuration. Fixed for the mount's lifetime. */
view?: boolean
/** Opens the agent's configuration, offered beside the model it runs with. */
onOpenConfig?: () => void
/** A view's form mode: the run form, drawn by the page. Kept out of this component because
* the flow editor reaches it, and the run form reads the navigation workspace. */
viewForm?: import('svelte').Snippet<
[
{
schema: typeof AGENT_CHAT_SCHEMA
run: (scheduledFor: string | undefined, args: Record<string, any>) => Promise<void>
loading: boolean
actions: import('svelte').Snippet | undefined
}
]
>
}
let {
path,
workspace,
enableAi = false,
toolId = undefined,
onSelectTool = undefined,
onSaved = undefined,
isNew = false,
view = false,
onOpenConfig = undefined,
viewForm = undefined
}: Props = $props()
/** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a
* schema per tool — the linked branch deliberately loads none. Reserved in `forbiddenIds`:
* the root shares a flow-state map with the tools, so a tool of this id would lose its
* schema to the root's. */
const AGENT_ID = '__wm_agent_root'
const draft = useAgentDraft({
path: () => path,
workspace: () => workspace,
isNew: () => isNew,
deployedOnly: () => view
})
/** Read access only. Everything that could write is blocked, down to the draft itself: an
* autosave the server rejects would look like a save and lose the edit. Running the agent,
* its history and its evals stay open, none of them being a write to the resource. */
let readOnly = $derived(view || !draft.canWrite)
const flowStore = $state({
val: {
summary: '',
value: { modules: [] },
extra_perms: {},
schema: emptySchema()
} as OpenFlow
}) as StateStore<OpenFlow>
const flowStateStore = $state({ val: {} }) as StateStore<FlowState>
const previewArgs = $state({ val: {} })
const stepsInputArgs = new StepsInputArgs()
const modulesTestStates = new ModulesTestStates(() => {})
const selectionManager = new SelectionManager()
selectionManager.selectId(AGENT_ID)
const history = initHistory(flowStore.val)
// The agent's own path, naming a flow that exists only here. It must be a real workspace path:
// `require_path_read_access_for_preview` rejects a first segment other than `u`/`f`/`hub` for
// anyone who is not an admin. It is therefore not unique to this editor — a flow may carry the
// same string — so telling the two apart reads `agentEditorHost` below, never the path.
const pathStore = writable('')
$effect(() => {
pathStore.set(path)
})
// Drilling into a tool is navigation in this editor, not a graph selection.
selectionManager.setOnSelectIntent?.((id: string) => {
if (id === AGENT_ID) {
onSelectTool?.(undefined)
return true
}
if (tools.some((t) => t?.id === id)) {
onSelectTool?.(id)
return true
}
return false
})
// The panel-placement control belongs to a step panel, not to this editor: left alone the card
// header would draw a placement picker and an X that closes the panel behind us.
setContext<FlowPanelDetachContext>('flowPanelDetach', {
claim: () => () => {},
modalOpen: () => false,
close: () => {},
enabled: () => false,
preference: () => 'auto',
setPreference: () => {}
})
// Inherit the host's context where there is one (keeps "Edit the script's code" and the AI
// chat's editor handle working), then override everything that must be private or the editor
// would write into the host flow. Built once and mutated: Svelte snapshots the parent context
// map per component on first access, so a later `setContext` is invisible to descendants.
const outer = getContext<FlowEditorContext | undefined>('FlowEditorContext')
setContext<FlowEditorContext>('FlowEditorContext', {
scriptEditorDrawer: writable(undefined),
workspaceScriptSettingsDrawer: writable(undefined),
flowEditorDrawer: writable(undefined),
initialPathStore: writable(''),
fakeInitialPath: '',
flowInputsStore: writable<FlowInput>({}),
customUi: {},
insertButtonOpen: writable(false),
executionCount: writable(0),
flowInputEditorState: writable<FlowInputEditorState>({
selectedTab: undefined,
editPanelSize: undefined,
payloadData: undefined
}),
currentEditor: writable(undefined),
outputPickerOpenFns: {},
preserveOnBehalfOf: writable(false),
savedOnBehalfOfEmail: writable<string | undefined>(undefined),
savedOnBehalfOfPermissionedAs: writable<string | undefined>(undefined),
...(outer ?? {}),
flowStore,
flowStateStore,
previewArgs,
stepsInputArgs,
modulesTestStates,
selectionManager,
history,
pathStore,
opWorkspace: () => workspace,
agentEditorHost: () => path,
saveDraft: () => {}
} as FlowEditorContext)
// PropPickerWrapper destructures this without a guard, so it throws without it.
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
pickablePropertiesFiltered: writable(undefined),
inModalPanel: () => true
})
// Its own, empty, rather than the flow's: the copilot addresses fields by name alone, and this
// editor is a descendant of the flow it was opened from, so a step input it fills would also
// land in a field of the agent that happens to share the name.
setContext<FlowCopilotContext>('FlowCopilotContext', {
shouldUpdatePropertyType: writable({}),
stepInputsLoading: writable(false),
generatedExprs: writable({}),
exprsToSet: writable({})
})
let agentModule = $derived(flowStore.val.value.modules?.[0])
let agentValue = $derived(
agentModule?.value.type === 'aiagent' ? (agentModule.value as any) : undefined
)
// The resource's own array, never a copy: the tool drawer writes replacements back through
// `tools[toolIndex]`, and a copy would take them instead of the value being edited. Only the
// container is shape-checked — `tools` is JSON-authored, and a `.filter` on another shape
// throws; entries are read with `?.` wherever they are dereferenced.
let tools = $derived(Array.isArray(agentValue?.tools) ? (agentValue.tools as AgentTool[]) : [])
let toolIndex = $derived(toolId ? tools.findIndex((t) => t?.id === toolId) : -1)
let tool = $derived(toolIndex >= 0 ? tools[toolIndex] : undefined)
// The caller owns which tool is open, so the drawer follows it rather than holding that state
// itself; closing it (Escape, the X, the overlay) reports back through `on:close`.
let toolDrawer: Drawer | undefined = $state(undefined)
$effect(() => {
const open = tool !== undefined
untrack(() => (open ? toolDrawer?.openDrawer() : toolDrawer?.closeDrawer()))
})
// The config the synthetic module last carried, so the two directions below can tell an edit
// apart from the echo of their own write.
let lastArgs = $state<string | undefined>(undefined)
// Picked on the first load, after which it is the reader's: following the memory setting live
// would pull them out of a chat for switching memory off in the form.
let testMode = $state<'form' | 'chat' | undefined>(undefined)
// Mounted the first time it is shown, then only hidden: unmounting destroys the SDK chat,
// which stops following the turn in flight, and its answer would never show.
let chatMounted = $state(false)
function showTestMode(mode: 'form' | 'chat') {
testMode = mode
if (mode === 'chat') chatMounted = true
}
// draft.args -> module. Only when the draft moved on its own (a load, an external write, a
// discard); an edit made in the form arrives here as its own echo and is skipped.
$effect(() => {
const args = draft.state?.args
if (draft.loading || !args) return
const serialized = JSON.stringify(args)
untrack(() => {
if (serialized === lastArgs) return
lastArgs = serialized
const built: FlowModule = {
id: AGENT_ID,
value: {
type: 'aiagent',
tools: (args.tools ?? []) as any,
input_transforms: agentArgsToTransforms(args) as any
} as any
}
flowStore.val.value.modules = [built]
if (testMode === undefined) showTestMode(keepsManagedMemory(args.memory) ? 'chat' : 'form')
// Caught rather than left to float: a tool whose shape the schema loader cannot read
// would otherwise reject into the global unhandled-rejection handler, which reports the
// bare message and no stack — saying nothing about which agent or tool caused it. The
// form still renders; only the inferred tool schemas are missing.
initFlowState(flowStore.val as Flow, flowStateStore, workspace, path).catch((err) =>
console.error('agent editor: could not infer tool schemas for', path, err)
)
})
})
/** Everything the form does not model. `inputTransformsToAgentConfig` rebuilds the value from
* `AGENT_BRAIN_KEYS` alone, so a key this editor never renders — one a newer backend added, or
* the `user_message` default a resource may carry, which the runtime does read when the step
* supplies none — would be dropped into the draft merely by opening the agent. */
const MODELLED_AGENT_KEYS = new Set<string>([...AGENT_BRAIN_KEYS, 'tools'])
function unmodelledArgs(args: AIAgentConfig | undefined): Record<string, unknown> {
return Object.fromEntries(
Object.entries(args ?? {}).filter(([key]) => !MODELLED_AGENT_KEYS.has(key))
)
}
// module -> draft.args. `inputTransformsToAgentConfig` drops the `{static, undefined}`
// placeholders `loadFlowModuleState` backfills, so the round trip is idempotent.
$effect(() => {
const v = agentValue
if (draft.loading || readOnly || !v || !draft.state) return
const next = {
...unmodelledArgs(draft.state.args),
...(inputTransformsToAgentConfig(
v.input_transforms as Record<string, InputTransform>,
(v.tools ?? []) as AgentTool[]
) as AIAgentConfig)
} as AIAgentConfig
// A config written before this editor spells "unset" its own way: an explicit `null` on a
// field, or no `tools` key at all. `inputTransformsToAgentConfig` writes neither, and that
// difference alone would read as an edit and autosave a draft for an agent nobody touched.
// Only where the two spellings mean the same thing — a value the user actually cleared is
// absent from `next` with something else in the draft, and stays cleared.
for (const [key, value] of Object.entries(draft.state.args ?? {})) {
if (value === null && !(key in next)) {
;(next as Record<string, unknown>)[key] = null
}
}
if (next.tools?.length === 0 && draft.state.args?.tools === undefined) {
delete next.tools
}
const serialized = JSON.stringify(next)
untrack(() => {
if (serialized === lastArgs) return
if (draft.state && !deepEqual(draft.state.args, next)) {
lastArgs = serialized
draft.state.args = next
}
})
})
let schema = $derived(flowStateStore.val[AGENT_ID]?.schema ?? {})
const brainFilter = [...AGENT_BRAIN_KEYS, AGENT_TOOLS_ROW]
let stepPropPicker = $derived(
agentModule
? getStepPropPicker(
flowStateStore.val,
undefined,
undefined,
AGENT_ID,
flowStore.val,
previewArgs.val,
false
)
: undefined
)
let testJob: Job | undefined = $state(undefined)
let testIsLoading = $state(false)
let scriptProgress = $state(undefined)
let chatPath = $derived(agentChatPath(path))
/** One chat turn: the agent as edited, not as deployed, the same as a run from the form. */
async function runChatTurn(
userMessage: string,
conversationId: string,
inputs?: Record<string, any>
): Promise<string | undefined> {
if (!agentModule) return undefined
// A view runs the deployed agent as its on-behalf-of identity, which anyone who can read it
// may do; the editor previews the draft as the one editing it.
if (view && workspace) {
return await JobService.runAgent({
workspace,
path,
memoryId: conversationId,
requestBody: { ...(inputs ?? {}), user_message: userMessage }
})
}
return await runFlowPreview(
{ ...(inputs ?? {}), user_message: userMessage },
agentChatFlow($state.snapshot(agentModule) as FlowModule),
chatPath,
undefined,
conversationId,
undefined,
workspace
)
}
let runLoading = $state(false)
/** One run of the deployed agent from the view's form, outside any conversation, then onto its
* run page. */
async function runOnce(_scheduledFor: string | undefined, args: Record<string, any>) {
if (!workspace) return
runLoading = true
try {
const id = await JobService.runAgent({ workspace, path, requestBody: args })
await goto(`/run/${id}?workspace=${workspace}`)
} finally {
runLoading = false
}
}
// What the composer reads for its model button and paperclip.
let chatModules = $derived(agentModule ? agentChatFlow(agentModule).value.modules : undefined)
let chatGap = $derived(agentChatGap(agentValue?.input_transforms))
function turnOnMemory() {
if (!agentValue) return
agentValue.input_transforms.memory = {
type: 'static',
value: structuredClone(DEFAULT_AGENT_MEMORY)
}
}
function turnOffMemory() {
if (!agentValue) return
agentValue.input_transforms.memory = { type: 'static', value: { kind: 'off' } }
}
function turnOnStreaming() {
if (!agentValue) return
agentValue.input_transforms.streaming = { type: 'static', value: true }
}
// Adding a tool goes straight into it: the editor has no graph to show the new node on, so the
// tool it just created is the only place the click can land.
async function addTool(detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) {
if (!agentValue) return
// Adding a tool is the explicit action that replaces a JSON-authored non-list; without this
// the insert goes into a value that cannot hold it.
if (!Array.isArray(agentValue.tools)) agentValue.tools = []
const id = await insertAgentTool(
flowStore,
flowStateStore,
agentValue,
detail,
workspace,
!enableAi
)
if (id) onSelectTool?.(id)
}
/** The mirror of `addTool`. These tools live in the resource rather than in a flow's module
* list, so the graph's delete has nothing here to act on. */
function deleteTool(id: string) {
if (!agentValue) return
const remaining = tools.filter((t) => t?.id !== id)
if (remaining.length === tools.length) return
agentValue.tools = remaining
delete flowStateStore.val[id]
if (toolId === id) onSelectTool?.(undefined)
}
/** The path field's own verdict (a taken path, an invalid name), which the server would otherwise
* only report after the request. The field lives with whoever shows it, which reports here. */
let pathError = $state('')
export function setPathError(error: string | undefined) {
pathError = error ?? ''
}
/** Who the next deploy makes the agent run as, as the "Permissioned as" line picked it. Unset
* while the line was never shown, which keeps the current identity as the line would have
* preselected it: an admin deploying without looking must not take the agent over. */
let runAs = $state<AgentRunAs | undefined>(undefined)
export function setRunAs(next: AgentRunAs) {
runAs = next
}
export function deploy(): Promise<boolean> {
if (pathError) {
sendUserToast(`Cannot deploy the agent: ${pathError}`, true)
return Promise.resolve(false)
}
const keep = draft.onBehalfOf ? { permissionedAs: draft.onBehalfOf, preserve: true } : undefined
return draft.deploy(runAs ?? keep).then(async (written) => {
// The path the write landed on, which a rename moves off the one this editor opened.
if (written) await onSaved?.(written)
return written !== undefined
})
}
export function draftHandle() {
return draft
}
/** The arguments a tool takes, as this editor inferred them; undefined until it has. */
export function toolSchema(id: string): any {
return flowStateStore.val[id]?.schema
}
/** The switch between the form and the chat, which the dialog's header holds. `mode` is
* undefined until the agent loads and picks the first one. */
export function testPaneHandle() {
return {
get mode() {
return testMode
},
set mode(mode: 'form' | 'chat' | undefined) {
if (mode) showTestMode(mode)
}
}
}
</script>
<!-- Beside the model in the chat's composer, as the AI session's assistant settings sit. -->
{#snippet configButton()}
<Tooltip small placement="top">
<Button
unifiedSize="2xs"
variant="subtle"
iconOnly
startIcon={{ icon: SlidersHorizontal }}
aria-label="Agent configuration"
onClick={() => onOpenConfig?.()}
/>
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">Agent configuration</p>
<p class="mt-1">The model, instructions and tools this agent runs with.</p>
</div>
{/snippet}
</Tooltip>
{/snippet}
<!-- In the form's Run row, where a schedulable form keeps its Advanced options. -->
{#snippet configurationButton()}
<Tooltip small placement="top">
<Button
unifiedSize="md"
variant="default"
iconOnly
startIcon={{ icon: SlidersHorizontal }}
aria-label="Agent configuration"
onClick={() => onOpenConfig?.()}
/>
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">Agent configuration</p>
<p class="mt-1">The model, instructions and tools this agent runs with.</p>
</div>
{/snippet}
</Tooltip>
{/snippet}
{#if draft.refusal}
<div class="h-full flex items-center justify-center px-8">
<Alert type="error" size="sm" title={draft.refusal} class="max-w-lg">
Open it from the resources page to see what it holds. Any unsaved edits are kept as a draft
there.
</Alert>
</div>
{:else if draft.loading}
<div class="h-full flex items-center justify-center text-xs text-tertiary">Loading agent...</div>
{:else if agentModule && agentValue}
<!-- Named and positioned so the tool picker's popover can portal here: the `#flow-editor` it
otherwise targets is behind this dialog, and does not exist at all on the resources page. -->
<div id="agent-editor" class="relative h-full min-h-0">
{#if view}
<!-- A view only runs the agent: the page beside it summarizes the configuration. -->
{@render runPane()}
{:else}
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
answer want opposite splits, and only the reader knows which they are on. -->
<Splitpanes class="h-full">
<Pane size={55} minSize={30}>{@render configPane()}</Pane>
<Pane size={45} minSize={20}>{@render runPane()}</Pane>
</Splitpanes>
{/if}
</div>
{#snippet configPane()}
<div class="h-full min-h-0 overflow-auto">
<PropPickerWrapper pickableProperties={stepPropPicker?.pickableProperties} noPadding sidePane>
<AiAgentStepInputs
class="px-4 pt-4 pb-8"
{schema}
filter={brainFilter}
previousModuleId={undefined}
pickableProperties={stepPropPicker?.pickableProperties}
extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'}
{enableAi}
{workspace}
staticOnly
visibilityKey={`agent:${path}`}
{tools}
{readOnly}
onSelectTool={(id) => onSelectTool?.(id)}
onAddTool={readOnly ? undefined : addTool}
onDeleteTool={readOnly ? undefined : deleteTool}
toolPickerPortal="#agent-editor"
bind:args={
() => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>,
(v) => agentValue && (agentValue.input_transforms = v)
}
/>
</PropPickerWrapper>
</div>
{/snippet}
{#snippet runPane()}
<div class="h-full min-h-0 flex flex-col">
<!-- Laid out as the script editor's preview column is: what a run takes above what it
produced, both alongside what is being edited. -->
{#if view}
<!-- A run of the deployed agent is a run like any other item's, so it opens on its
own page rather than as a test result beside the form. -->
<!-- Centered at the width the chat's column keeps, so switching modes moves nothing. -->
<div class="flex-1 min-h-0 overflow-auto p-4 {testMode === 'chat' ? 'hidden' : ''}">
<div class="max-w-3xl mx-auto">
{@render viewForm?.({
schema: AGENT_CHAT_SCHEMA,
run: runOnce,
loading: runLoading,
actions: onOpenConfig ? configurationButton : undefined
})}
</div>
</div>
{:else}
<div class="flex-1 min-h-0 flex flex-col {testMode === 'chat' ? 'hidden' : ''}">
<!-- An agent reused as a step has no use for memory unless its flow gives it a
conversation, and the form is where such an agent is tried. -->
{#if !chatGap?.memory && !readOnly}
<Alert type="info" size="xs" title="Managed memory is on" class="m-2 shrink-0">
It keeps the conversation between runs, which is useful if you chat with this agent,
use it in a flow in chat mode, or pass it a memory id. Otherwise, you can turn it off.
<div class="flex mt-2">
<Button unifiedSize="2xs" variant="default" onClick={turnOffMemory}>Turn off</Button
>
</div>
</Alert>
{/if}
<Splitpanes horizontal class="flex-1 min-h-0">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
/>
</div>
</Pane>
<Pane size={60} minSize={20}>
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</Pane>
</Splitpanes>
</div>
{/if}
{#if chatMounted}
<div class={testMode === 'chat' ? 'flex flex-col flex-1 min-h-0' : 'hidden'}>
{#if chatGap?.memory}
<div class="flex-1 flex flex-col items-center justify-center gap-2 px-8 text-center">
<MessageCircleOff size={48} class="text-tertiary opacity-50 mb-2" />
<p class="text-sm font-semibold text-emphasis">Chat needs managed memory</p>
<p class="text-xs text-secondary max-w-xs">
{chatGap.memoryCanTurnOn
? 'Without it, every message would be answered without the ones before it.'
: 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'}
</p>
{#if chatGap.memoryCanTurnOn && !readOnly}
<Button
unifiedSize="sm"
variant="default"
btnClasses="bg-surface mt-2"
onClick={turnOnMemory}
>
Turn on managed memory
</Button>
{/if}
</div>
{:else if chatGap?.noStream}
<Alert
type="info"
size="xs"
title={chatGap.noStream === 'image'
? 'Image answers do not stream'
: 'Streaming is off'}
class="m-2 shrink-0"
>
Each answer shows once its run ends.
{#if chatGap.noStream === 'off' && !readOnly}
<div class="flex mt-2">
<Button unifiedSize="2xs" variant="default" onClick={turnOnStreaming}
>Turn on</Button
>
</div>
{/if}
</Alert>
{/if}
<!-- Hidden rather than unmounted while memory is off: switching it off mid-turn must
not end the chat following that turn. Test chats, since what runs is the agent as
edited. -->
<div class={chatGap?.memory ? 'hidden' : 'flex flex-col flex-1 min-h-0'}>
<FlowChat
onRunFlow={runChatTurn}
path={chatPath}
conversationKind={view ? 'deployed' : 'test'}
subject="agent"
frame="none"
inputSchema={AGENT_CHAT_SCHEMA}
flowModules={chatModules}
composerSettings={onOpenConfig ? configButton : undefined}
/>
</div>
</div>
{/if}
</div>
{/snippet}
<!-- A tool is a whole step editor, so it gets a surface of its own rather than a level of the
dialog: the agent stays visible behind it, along with the banner and Deploy that its edits
feed. -->
<Drawer bind:this={toolDrawer} size="1200px" on:close={() => onSelectTool?.(undefined)}>
<DrawerContent
title={(tool ? toolDisplayName(tool) : undefined) ?? 'Edit tool'}
on:close={() => toolDrawer?.closeDrawer()}
noPadding
>
{#if tool}
<!-- Inert to a read-only viewer for the same reason the form above is, and opened on its
configuration rather than on a test it cannot fill in. -->
<div class="h-full min-h-0 flex flex-col {readOnly ? 'opacity-60' : ''}" inert={readOnly}>
<AgentToolWrapper
bind:tool={() => tools[toolIndex], (v) => (tools[toolIndex] = v)}
parentModule={agentModule as FlowModule}
{enableAi}
staticOnly
forceTestTab={readOnly ? undefined : { [tool.id]: true }}
siblingToolNames={tools.filter((t) => t?.id !== tool?.id).map((t) => t?.summary ?? '')}
/>
</div>
{/if}
</DrawerContent>
</Drawer>
{/if}