feat(frontend): group the agent form and edit saved agents as drafts (#10880)

* feat(frontend): group the AI agent step form and edit saved agents in a modal

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: edit a saved AI agent through its own resource draft

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: drop the agent fork-for-edit session now that edits live in a draft

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: edit ai_agent resources from the resources page with the agent editor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: send a standalone agent's brain from the module when testing a step

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep the agent draft faithful to the resource it deploys to

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: add the sqlx cache entry for the eval subject rename

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: share the module insert between the graph and the agent editor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(frontend): open evals inside the agent editor, actions in its header

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(frontend): add tools from the agent editor and lighten its test pane

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): open an ai_agent deep link in the agent editor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): drop the failed result badge on a step that never ran

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(frontend): head the agent editor's levels with a back control

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(frontend): drop connect and fill inputs from the agent editor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(frontend): lighten the agent editor's run panel

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): stop a nested agent tool's config reading as AI-filled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(frontend): offer only AI or static on an agent tool's inputs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(frontend): give a saved agent's tool editor a static-only surface

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(frontend): open an agent tool in a drawer beside the agent

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(frontend): hide unset agent config in the run form

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(frontend): share the input forms' pickers and s3 lookup

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(frontend): drop a dead agent-editor export and fix two stale comments

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): reach an ai_agent's resource-level settings and copilot

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): open an ai_agent's resource view as JSON, not the generic form

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): address review findings on the agent editor's draft and streaming

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): close the agent editor on a version restore, as the resource editor does

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): stop the provider picker auto-writing a kind, and clear review nits

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(frontend): drop the fork-for-edit leftovers from the agent card

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): mount the agent editor in the dev flow editor and guard the deep-link race

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): deploy the agent config that was submitted, and refuse one no run could use

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(frontend): build the agent editor's rows from the design-system button

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): keep a draft-only agent's draft, and let a blank MCP summary deploy

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): guard read-only agents, incomplete MCP tools and duplicate editor mounts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read-only agent editor, linked-card refresh, atomic eval rename

* fix: eval rename needs the privileged pool, per-workspace write access

* fix(frontend): drop the agent editor target when its mount goes away

* refactor: drop the agent rename work from this PR, unban the bindable defaults

* fix(frontend): refuse a renaming deploy and drop the copilot from static-only fields

* fix(frontend): mirror the worker's streaming rule and scope agent writes to their target

* fix(frontend): read runtime streaming as off and reset the drawer's json view

* fix(frontend): read an unsettled output_type as non-streaming too

* fix(frontend): let the showing modal claim an agent opened from inside it

* fix(frontend): keep in-flight edits, tool replacements and every linked step in sync

* fix(frontend): keep attachments in the run form and bind the agent ref to its tools

* fix(frontend): preview the agent as authored and re-evaluate step args on run

* fix(frontend): scope agent-editor ownership to the flow's workspace

* fix(frontend): drop the tool drill-in where there is no graph to select on

* fix(frontend): require a provider kind and keep one resource editor open at a time

* fix(frontend): keep legacy nulls, static-only text literal, and the handover anchor

* test(worker): pin the agent streaming default

* fix(frontend): let an AI-fillable input be switched to static

* fix(frontend): report agent editor background failures instead of floating them

* fix(frontend): keep the version pane's path alive while the editor closes

* fix(frontend): clear the anchor-keep flag at the start of each drawer session

* fix(frontend): preview the agent without its synthetic path, refresh the baseline on external writes

* refactor(frontend): drop the unverifiable baseline refresh, state the synthetic-path rule

* fix(frontend): keep the synthetic path out of agent tool test runs too

* refactor(frontend): host the agent editor under the agent's own path

* fix(frontend): mark an agent editor's host explicitly instead of inferring it from the path

* fix(frontend): discard linked-agent responses from before a deploy

* fix(frontend): keep a flow mount from claiming an agent editor's nested target

* feat(frontend): keep an agent used as a tool inside the agent being edited

* fix(frontend): reserve the agent editor's root module id

* docs(frontend): record why the agent editor previews under the agent's path

* fix(frontend): refuse to open or deploy a resource that is not an agent

* docs(frontend): put the scope-migration comment on the function it describes

* fix(frontend): refuse an agent path whose resource type is not proven

* fix(frontend): recheck the resource type before deploying, and keep expressions off static-only inputs

* fix(frontend): lazy-load the agent editor and slide its levels like the evals pane

* refactor: drop unreachable non-list tools check from agent deploy

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): clear text-only agent fields on image output, reserve the root id

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): keep the agent editor usable for a non-list tools value

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): stop the parked eval run list from taking arrow keys

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): report a non-list tools value on deploy instead of throwing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): keep temperature editable for image output

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): skip non-object tool entries when rendering an agent

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): guard tool entry reads instead of copying the tool array

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): key tool rows by position so duplicate ids render

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-04 16:32:17 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 64b6798799
commit f037c73d10
60 changed files with 3723 additions and 1278 deletions
+19 -1
View File
@@ -1099,7 +1099,7 @@ pub async fn run_agent(
// For non-Anthropic providers, response_format is handled by the query builder
}
let user_wants_streaming = args.streaming.unwrap_or(false);
let user_wants_streaming = streaming_requested(args.streaming);
*has_stream = user_wants_streaming && is_text_output;
let mut final_events_str = String::new();
@@ -1701,6 +1701,17 @@ pub async fn run_agent(
}))
}
/// Whether the step asked for its answer as it is generated. Absence means on, matching the
/// schema's own default: a step that never wrote the key never had an opinion, and an answer
/// arriving as it is written is what people expect. Only an explicit `false` holds it back.
///
/// The chat surfaces decide whether to open a stream from their own reading of the same config,
/// and a surface that opens one for an answer sent in a single piece re-runs the flow when the
/// connection times out. So this default is half of a contract, not a local preference.
fn streaming_requested(streaming: Option<bool>) -> bool {
streaming.unwrap_or(true)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1713,6 +1724,13 @@ mod tests {
}
}
#[test]
fn an_unwritten_streaming_field_streams() {
assert!(streaming_requested(None));
assert!(streaming_requested(Some(true)));
assert!(!streaming_requested(Some(false)));
}
/// Over 64 characters OpenAI rejects the key outright, which costs a wasted round
/// trip per run and silently leaves that step with no prompt caching at all.
#[test]
+2
View File
@@ -35,6 +35,8 @@ export interface SchemaProperty {
}
min?: number
max?: number
/** Height a string field's text area opens at, in rows. */
minRows?: number
currency?: string
currencyLocale?: string
multiselect?: boolean
@@ -4,11 +4,7 @@
import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib'
import type { AIProvider, ProviderConfig } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { get } from 'svelte/store'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ResourcePicker from './ResourcePicker.svelte'
import ToggleButtonMore from './common/toggleButton-v2/ToggleButtonMore.svelte'
import Toggle from './Toggle.svelte'
import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage'
import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte'
@@ -17,9 +13,20 @@
value: ProviderConfig | undefined
disabled?: boolean
actions?: Snippet
/** The workspace the surface operates on, which a session or fork editor sets to something
* other than the one being navigated. Resources and the models read off them are per
* workspace, so without it this offers what the wrong one holds. */
workspace?: string | undefined
}
let { value: _uncheckedValue = $bindable(), disabled = false, actions }: Props = $props()
let {
value: _uncheckedValue = $bindable(),
disabled = false,
actions,
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore ?? '')
let value = $derived.by(() => {
if (!_uncheckedValue || typeof _uncheckedValue !== 'object') return undefined
@@ -30,7 +37,13 @@
let availableModels = $state<string[]>([])
let filterText = $state('')
let modelsCache = new Map<AIProvider, string[]>()
// Keyed by provider *and* path: two `customai` resources point at different base URLs, so they
// do not share a model list.
let modelsCache = new Map<string, string[]>()
// The resource picker offers every provider type at once and the pick is what names the kind.
// One string for the component's life: it is what the picker queries with.
const providerResourceTypes = Object.keys(AI_PROVIDERS).join(',')
if (!_uncheckedValue) {
_uncheckedValue = {
@@ -57,12 +70,6 @@
return r
})
// Provider options for the toggle button group
const providerOptions = Object.entries(AI_PROVIDERS).map(([key, details]) => ({
value: key as AIProvider,
label: details.label
}))
async function loadModels(signal?: AbortSignal) {
const provider = value?.kind
const resourceValue = value?.resource
@@ -73,20 +80,20 @@
}
loading = true
if (modelsCache.has(provider)) {
availableModels = modelsCache.get(provider) || []
const cacheKey = `${effectiveWorkspace}:${provider}:${resourcePath}`
if (modelsCache.has(cacheKey)) {
availableModels = modelsCache.get(cacheKey) || []
loading = false
return
}
try {
const workspace = get(workspaceStore) || ''
const models = await fetchAvailableModels(resourcePath, workspace, provider, signal)
const models = await fetchAvailableModels(resourcePath, effectiveWorkspace, provider, signal)
if (signal?.aborted) {
return
}
availableModels = models
modelsCache.set(provider, models)
modelsCache.set(cacheKey, models)
} catch (e) {
if (signal?.aborted) {
return
@@ -101,15 +108,24 @@
}
}
// Handle provider selection
function onProviderChange(selectedProvider: AIProvider) {
if (value) {
value.kind = selectedProvider
value.resource = ''
value.model = ''
// Reasoning effort is model-specific; reset it with the model.
value.reasoning_effort = undefined
/**
* The provider kind follows the resource that was picked. Driven by the pick rather than by an
* effect on the picker's `valueType`, which also resolves for the value the field was opened on
* and would rewrite a saved config just for being looked at.
*/
function onResourcePicked(_path: string | undefined, type: string | undefined) {
// An empty type is the placeholder the picker keeps for a saved path it could not find. It
// says nothing about the provider, so the kind stands.
if (!value || !type || !(type in AI_PROVIDERS)) {
return
}
if (value.kind === type) {
return
}
value.kind = type as AIProvider
// Models are per provider, and a reasoning token is per model.
value.model = ''
value.reasoning_effort = undefined
}
// Helper functions to handle $res: prefix like ObjectResourceInput does
@@ -165,97 +181,74 @@
})
</script>
<div class="w-full flex flex-col gap-1 border rounded-md p-4">
<!-- Provider Selection -->
<ToggleButtonGroup
selected={value?.kind}
onSelected={onProviderChange}
{disabled}
wrap
tabListClass="w-full"
>
{#snippet children({ item })}
{#each providerOptions.slice(0, 3) as option}
<ToggleButton value={option.value} label={option.label} {item} />
{/each}
<ToggleButtonMore
class="ml-auto"
btnText={providerOptions.findIndex((p) => p.value === value?.kind) >= 3 ? '' : 'More'}
togglableItems={providerOptions.slice(3)}
{item}
bind:selected={() => value?.kind, (v) => v && onProviderChange(v)}
/>
{/snippet}
</ToggleButtonGroup>
<!-- Resource Selection -->
<div class="flex flex-col rounded-md pt-2 gap-2">
<div class="flex flex-col gap-1">
<p class="text-xs font-normal text-primary">resource</p>
<ResourcePicker
bind:value={
() => resourceValueToPath(value?.resource),
(v) => {
if (value) {
value.resource = pathToResourceValue(v) ?? ''
}
<div class="w-full flex flex-col gap-3 border rounded-md p-4">
<div class="flex flex-col gap-1">
<span class="text-xs font-normal text-secondary">Resource</span>
<!-- No auto-select: this picker spans every provider type, so a single candidate means "the
only AI resource in the workspace" rather than "the only one of the kind this agent uses".
Taking it would redefine the agent's provider and drop its model, on open and unasked. -->
<ResourcePicker
bind:value={
() => resourceValueToPath(value?.resource),
(v) => {
if (value) {
value.resource = pathToResourceValue(v) ?? ''
}
}
resourceType={value?.kind}
disabled={disabled || !value?.kind}
placeholder="Select resource"
selectFirst={true}
/>
</div>
}
resourceType={providerResourceTypes}
{disabled}
{workspace}
placeholder="Select an AI provider resource"
selectFirst={false}
onValueChange={onResourcePicked}
/>
</div>
<!-- Model Selection -->
<div class="flex flex-col gap-1">
<span class="text-xs font-normal text-secondary">Model</span>
<Select
{items}
bind:value={() => value?.model, (v) => value && (value.model = v ?? '')}
placeholder="Select model"
disabled={disabled || !value?.kind || !resourceValueToPath(value?.resource)}
onCreateItem={(r) => {
availableModels.push(r)
if (value) value.model = r
}}
createText="Press enter to use custom model"
{loading}
clearable={false}
noItemsMsg={'No models available'}
bind:filterText
/>
</div>
{#if value?.model}
<div class="flex flex-col gap-1">
<p class="text-xs font-normal text-primary">model</p>
<Select
{items}
bind:value={() => value?.model, (v) => value && (value.model = v ?? '')}
placeholder="Select model"
disabled={disabled || !value?.kind || !resourceValueToPath(value?.resource)}
onCreateItem={(r) => {
availableModels.push(r)
if (value) value.model = r
}}
createText="Press enter to use custom model"
{loading}
clearable={false}
noItemsMsg={'No models available'}
bind:filterText
<span class="text-xs font-normal text-secondary">Reasoning effort</span>
<AIReasoningEffortPicker
bind:value={() => value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)}
providerConfig={value}
{disabled}
/>
</div>
{/if}
<!-- Reasoning Effort (shown once a model is selected) -->
{#if value?.model}
<div class="flex flex-col gap-1">
<p class="text-xs font-normal text-primary">reasoning effort</p>
<AIReasoningEffortPicker
bind:value={() => value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)}
providerConfig={value}
{disabled}
/>
</div>
{/if}
<!-- Use as Default Checkbox -->
<div class="flex justify-end pt-1">
<Toggle
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
bind:checked={useAsDefault}
options={{ right: 'Use as personal default for other new agents' }}
size="xs"
on:change={(e) => {
if (!e.detail) {
removeConfig()
} else {
saveConfig(value)
}
}}
/>
</div>
<div class="flex justify-end">
<Toggle
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
bind:checked={useAsDefault}
options={{ right: 'Use as personal default for other new agents' }}
size="xs"
on:change={(e) => {
if (!e.detail) {
removeConfig()
} else {
saveConfig(value)
}
}}
/>
</div>
{@render actions?.()}
+1 -1
View File
@@ -1447,7 +1447,7 @@
{showSchemaExplorer}
/>
{:else if inputCat == 'ai-provider'}
<AIProviderPicker bind:value {disabled} {actions} />
<AIProviderPicker bind:value {disabled} {actions} {workspace} />
{:else if inputCat == 'email'}
<input
{autofocus}
+4
View File
@@ -34,6 +34,7 @@
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
import FlowModuleSchemaMap from './flows/map/FlowModuleSchemaMap.svelte'
import FlowEditorPanel from './flows/content/FlowEditorPanel.svelte'
import AgentEditorModal from './flows/content/AgentEditorModal.svelte'
import { deepEqual } from 'fast-equals'
import { findModuleInFlow } from './flows/flowDiff'
import { writable } from 'svelte/store'
@@ -1310,5 +1311,8 @@
</Splitpanes>
</div>
</div>
<!-- Outside the panel, which is keyed on the selection: the dialog stays on the step it was
opened for while the graph is used behind it. -->
<AgentEditorModal enableAi owns={() => true} />
{/if}
</main>
@@ -44,6 +44,7 @@
import FlowRestartButton from './FlowRestartButton.svelte'
import { useNestedRestartState } from './useNestedRestartState.svelte'
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
import { agentStreamingEnabled } from './flows/agentFormFields'
interface Props {
previewMode: 'upTo' | 'whole'
@@ -163,11 +164,8 @@
let shouldUseStreaming = $derived.by(() => {
const modules = flowStore.val.value?.modules
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
return (
lastModule?.value?.type === 'aiagent' &&
lastModule?.value?.input_transforms?.streaming?.type === 'static' &&
lastModule?.value?.input_transforms?.streaming?.value === true
)
if (lastModule?.value?.type !== 'aiagent') return false
return agentStreamingEnabled(lastModule.value)
})
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
@@ -53,6 +53,13 @@
label?: string
/** Replaces the label header, so a setting's own toggle can name the field. */
header?: Snippet
/** Renders after the label: a button to unset the field, a badge. */
labelExtra?: Snippet
/** Drop the schema's description paragraph, for a form that carries it in a tooltip. */
hideDescription?: boolean
/** Keep the connect and transform controls out of the way until the row is reached, unless
* the field already holds something the controls are needed to read. */
subtleControls?: boolean
/** The kind this field always holds, for a value that doesn't carry a `type` of its
* own — a flow predicate is stored as a bare `{ expr }`. */
argType?: InputTransform['type']
@@ -76,6 +83,14 @@
/** Hide the static/expression switch, for a field that only ever holds one kind.
* The connect button and the AI helper stay. */
noDynamicToggle?: boolean
/** Hide the connect button, for a surface with nothing to connect to. Distinct from
* `noDynamicToggle`, which a field forced to an expression also sets. */
noConnect?: boolean
/** Drop the expression option, and every affordance that writes one: an expression reaching
* such a field is stored and deployed like any other, whichever control put it there. The
* rest of the switch stays, so a field can still be AI-filled or static. A field already
* holding an expression keeps the option, or it could not be switched off it. */
noJavascript?: boolean
/** Replaces the default StepInputGen, for a field with its own AI helper. That
* helper drives `suggestion` (its ghost text) and `aiOnKeyUp` (Tab to accept),
* which the built-in one reaches through `stepInputGen` instead. */
@@ -104,6 +119,9 @@
argName = $bindable(),
label = undefined,
header = undefined,
labelExtra = undefined,
hideDescription = false,
subtleControls = false,
argType = undefined,
collapsed = false,
animateAppear = false,
@@ -118,6 +136,8 @@
variableEditor = undefined,
itemPicker = undefined,
noDynamicToggle = false,
noConnect = false,
noJavascript = false,
aiGen = undefined,
suggestion = $bindable(),
focused = $bindable(),
@@ -183,6 +203,11 @@
allowedAiTransforms === undefined || allowedAiTransforms.includes(argName)
)
// A `${}` field is static text that interpolates JavaScript, so it is only on offer where
// expressions are. Elsewhere the same field is plain static: labelled `static`, edited in the
// ordinary input, with no `${...}` hint promising an escape hatch that isn't there.
let staticTemplateOffered = $derived(isStaticTemplate(inputCat) && !noJavascript)
// `argType` wins over whatever the value carries: a predicate has no `type` field, so
// inferring would land it on the static input instead of the expression editor.
const argKind = $derived(argType ?? arg?.type)
@@ -250,7 +275,11 @@
return
}
if (isCodeInjection(rawValue)) {
// `${...}` becomes a JavaScript transform, so it is only read as one where such a transform
// can be stored — the same condition `staticTemplateOffered` renders under. Elsewhere the
// text stays what was typed, rather than turning into code the store then drops or, worse,
// keeps pointing at a flow context this value will never be evaluated in.
if (isCodeInjection(rawValue) && !noJavascript) {
arg.expr = getDefaultExpr(
argName,
previousModuleId,
@@ -273,7 +302,12 @@
let codeInjectionDetected = $state(false)
function checkCodeInjection(rawValue: string) {
// A static value is whatever JSON the field holds, so it need not be a string, and the caller
// runs inside an effect: throwing here would take the whole form down rather than one field.
function checkCodeInjection(rawValue: unknown): { word: string; value: string }[] | undefined {
if (typeof rawValue !== 'string') {
return undefined
}
if (!arg || !rawValue || rawValue.length < 3 || !dynamicTemplateRegexPairs) {
return undefined
}
@@ -307,6 +341,7 @@
isStaticTemplate(inputCat) &&
propertyType == 'static' &&
!noDynamicToggle &&
!noJavascript &&
codeInjectionDetected
) {
setJavaScriptExpr(arg.value)
@@ -561,8 +596,16 @@
untrack(() => handleFieldVisibility(schema, arg, otherArgs))
})
let connecting = $derived($propPickerConfig?.propName == argName)
let fieldDescription = $derived(
hideDescription ? undefined : schema?.properties?.[argName]?.description
)
// Fading the controls away is only safe while the row itself says what it holds. An expression
// or an AI-filled value is only legible from the toggle, so those keep it on screen.
let controlsPinned = $derived(connecting || propertyType !== 'static' || Boolean(suggestion))
// Its picker builds an expression, so it goes with the expression option.
let shouldShowS3ArrayHelper = $derived(
inputCat === 'list' &&
!noJavascript &&
['s3object', 's3_object'].includes(schema?.properties?.[argName]?.items?.resourceType)
)
@@ -600,7 +643,9 @@
type={schema.properties?.[argName]?.type}
/>
{#if isStaticTemplate(inputCat)}
{@render labelExtra?.()}
{#if staticTemplateOffered}
<div>
<span
class="border text-gray-400 dark:text-gray-500 text-2xs font-medium mr-2 px-1 !py-[1px] rounded ml-2.5 {propertyType ==
@@ -616,13 +661,17 @@
</div>
<!-- Nothing to connect to or switch while collapsed: there is no value yet. -->
<div
class="flex flex-row items-end gap-x-2 z-10 absolute right-0 bottom-0 group-hover:bg-surface transition-colors {collapsed
? 'hidden'
: ''}"
class={twMerge(
'flex flex-row items-end gap-x-2 z-10 absolute right-0 bottom-0 group-hover:bg-surface transition-colors',
collapsed ? 'hidden' : '',
subtleControls && !controlsPinned
? 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity'
: ''
)}
>
{#if aiGen}
{@render aiGen()}
{:else if enableAi}
{:else if enableAi && !noJavascript}
<StepInputGen
bind:this={stepInputGen}
{focused}
@@ -639,7 +688,7 @@
/>
{/if}
{#if propPickerWrapperContext}
{#if propPickerWrapperContext && !noConnect}
<FlowPlugConnect
wrapperClasses={twMerge(
'group-hover:opacity-100 transition-opacity',
@@ -704,6 +753,11 @@
if (arg) {
arg.value = codeToStaticTemplate(arg.expr)
arg.expr = undefined
// Stated here, as the other branches state it. `setPropertyType`
// only writes a type when the text is an interpolation, so leaving
// it to that call means a field switched off `ai` keeps carrying
// `ai` and reads straight back as it on the next render.
arg.type = 'static'
}
setPropertyType(arg?.value)
} else if (inputCat == 'list' || inputCat == 'object') {
@@ -727,22 +781,33 @@
arg.expr = undefined
}
}
// On a field the agent can fill, "static with no value" is itself the
// AI state (see `getPropertyType`), so leaving the value unset reads
// this choice straight back as AI and the field can never be typed
// into. An empty value of the field's own kind is what makes "I will
// supply this one" representable.
if (fieldAllowsAi && arg && arg.value === undefined) {
arg.value = isStaticTemplate(inputCat) ? '' : null
}
propertyType = 'static'
}
}}
>
{#snippet children({ item })}
{#if fieldAllowsAi}
<!-- `h-full`, as its siblings have: the group is a row shorter than a `sm`
button, and without it this one stands proud of the others. -->
<ToggleButton
small
size="sm"
label="AI"
value="ai"
tooltip="Let the AI agent fill this field dynamically"
{item}
class="h-full text-xs"
/>
{/if}
{#if isStaticTemplate(inputCat)}
{#if staticTemplateOffered}
<ToggleButton
size="sm"
tooltip={`Write text or surround javascript with \`\$\{\` and \`\}\`. Use \`results\` to connect to another node\'s output.`}
@@ -761,7 +826,9 @@
/>
{/if}
{#if codeInjectionDetected && propertyType == 'static'}
{#if noJavascript && propertyType !== 'javascript'}
<!-- nothing: the expression option is not offered here -->
{:else if codeInjectionDetected && propertyType == 'static'}
<Button
size="xs2"
color="light"
@@ -847,19 +914,19 @@
This field will be filled by the AI agent dynamically
</span>
</div>
{#if argName && schema?.properties?.[argName]?.description}
{#if fieldDescription}
<div class="text-xs italic py-1 text-hint">
<pre class="font-main whitespace-normal">
{schema.properties[argName].description}
{fieldDescription}
</pre>
</div>
{/if}
{:else if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle}
{:else if staticTemplateOffered && propertyType == 'static' && !noDynamicToggle}
<div class="flex flex-col gap-1">
{#if argName && schema?.properties?.[argName]?.description}
{#if fieldDescription}
<div class="text-xs text-secondary">
<pre class="font-main whitespace-normal">
{schema.properties[argName].description}
{fieldDescription}
</pre>
</div>
{/if}
@@ -868,6 +935,8 @@
<TemplateEditor
bind:this={monacoTemplate}
{extraLib}
minRows={schema?.properties?.[argName]?.minRows}
placeholder={schema?.properties?.[argName]?.placeholder}
on:focus={onFocus}
on:blur={() => {
focused = false
@@ -898,7 +967,13 @@
}}
label={argName}
bind:editor={monaco}
bind:description={schema.properties[argName].description}
bind:description={
() => fieldDescription,
(v) => {
const property = schema.properties?.[argName]
if (!hideDescription && property) property.description = v
}
}
bind:value={arg.value}
type={schema.properties[argName].type}
oneOf={schema.properties[argName].oneOf}
@@ -995,11 +1070,9 @@
/>
{/if}
{#if argName && schema?.properties?.[argName]?.description}
{#if fieldDescription}
<div class="text-xs italic py-1 text-secondary">
<pre class="font-main whitespace-normal"
>{schema.properties[argName].description}</pre
>
<pre class="font-main whitespace-normal">{fieldDescription}</pre>
</div>
{/if}
@@ -0,0 +1,68 @@
<script lang="ts">
import { VariableService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { watch } from 'runed'
import { Plus } from 'lucide-svelte'
import { Button } from './common'
import ItemPicker from './ItemPicker.svelte'
import VariableEditor from './VariableEditor.svelte'
interface Props {
/** The transforms being edited. A picked variable is written into `pickForField`'s. */
args?: Record<string, any>
/** Which field the open picker is picking for, set by whichever row opened it. */
pickForField?: string | undefined
workspace?: string | undefined
/** Handed back so every field's form can open them. */
itemPicker?: ItemPicker | undefined
variableEditor?: VariableEditor | undefined
}
let {
args = {},
pickForField = undefined,
workspace = undefined,
itemPicker = $bindable(),
variableEditor = $bindable()
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
watch(
() => ws,
() => itemPicker?.reloadItems()
)
</script>
<ItemPicker
bind:this={itemPicker}
pickCallback={(path, _) => {
if (pickForField) {
args[pickForField].value = '$var:' + path
}
}}
itemName="Variable"
extraField="path"
loadItems={async () =>
(await VariableService.listVariable({ workspace: ws ?? '' })).map((x) => ({
name: x.path,
...x
}))}
>
{#snippet submission()}
<div class="flex flex-row-reverse w-full border-t border-gray-200 rounded-bl-lg rounded-br-lg">
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Plus }}
on:click={() => {
variableEditor?.initNew?.()
}}
>
New variable
</Button>
</div>
{/snippet}
</ItemPicker>
<VariableEditor bind:this={variableEditor} workspace={ws} />
@@ -1,17 +1,16 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { CancelError, VariableService, WorkspaceService, type InputTransform } from '$lib/gen'
import { type InputTransform } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
import { untrack } from 'svelte'
import { resource, watch } from 'runed'
import { Button } from './common'
import StepInputsGen from './copilot/StepInputsGen.svelte'
import type { PickableProperties } from './flows/previousResults'
import InputTransformForm from './InputTransformForm.svelte'
import ItemPicker from './ItemPicker.svelte'
import VariableEditor from './VariableEditor.svelte'
import { Plus } from 'lucide-svelte'
import InputTransformPickers from './InputTransformPickers.svelte'
import { useS3StorageConfigured } from './inputTransformEnv.svelte'
import type ItemPicker from './ItemPicker.svelte'
import type VariableEditor from './VariableEditor.svelte'
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
interface Props {
@@ -22,6 +21,9 @@
previousModuleId?: string | undefined
filter?: string[] | undefined
noDynamicToggle?: boolean
/** Passed to every field. See `InputTransformForm` for what each one drops. */
noConnect?: boolean
noJavascript?: boolean
pickableProperties?: PickableProperties | undefined
enableAi?: boolean
class?: string
@@ -40,6 +42,8 @@
previousModuleId = undefined,
filter = undefined,
noDynamicToggle = false,
noConnect = false,
noJavascript = false,
pickableProperties = undefined,
enableAi = false,
class: clazz = '',
@@ -82,37 +86,7 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const settings = resource(
() => ws,
async (ws, _previousWs, { onCleanup }) => {
if (!ws) return undefined
const req = WorkspaceService.getPublicSettings({ workspace: ws })
// `resource` keeps whatever lands last: cancel a superseded request so a slow
// reply for a workspace we have left cannot overwrite the current one.
onCleanup(() => req.cancel())
try {
return { ws, settings: await req }
} catch (err) {
if (!(err instanceof CancelError)) {
console.error('Failed to fetch workspace settings:', err)
}
return undefined
}
}
)
// Assume configured until this workspace's own answer lands: the warning must not
// linger from the previous workspace, nor appear merely because the fetch failed.
let s3StorageConfigured = $derived.by(() => {
const loaded = settings.current
return loaded && loaded.ws === ws
? loaded.settings.large_file_storage?.s3_resource_path !== undefined
: true
})
watch(
() => ws,
() => itemPicker?.reloadItems()
)
const s3Storage = useS3StorageConfigured(() => ws)
let keys: string[] = $state([])
$effect(() => {
@@ -125,7 +99,10 @@
</script>
<div class="w-full mb-6 {clazz}">
{#if enableAi}
<!-- Not offered on a tool: the fields it fills are exactly the ones left empty for the agent
to fill at run time, and filling one rewrites it into an expression, which takes it out of
the schema the model is given. -->
{#if enableAi && !isAgentTool}
<div class="pt-2">
<StepInputsGen
{pickableProperties}
@@ -160,12 +137,14 @@
{itemPicker}
bind:pickForField
{noDynamicToggle}
{noConnect}
{noJavascript}
{pickableProperties}
{enableAi}
{helperScript}
{isAgentTool}
{allowedAiTransforms}
{s3StorageConfigured}
s3StorageConfigured={s3Storage.current}
{chatInputEnabled}
{workspace}
otherArgs={Object.fromEntries(
@@ -180,35 +159,4 @@
{/if}
</div>
<ItemPicker
bind:this={itemPicker}
pickCallback={(path, _) => {
if (pickForField) {
args[pickForField].value = '$var:' + path
}
}}
itemName="Variable"
extraField="path"
loadItems={async () =>
(await VariableService.listVariable({ workspace: ws ?? '' })).map((x) => ({
name: x.path,
...x
}))}
>
{#snippet submission()}
<div class="flex flex-row-reverse w-full border-t border-gray-200 rounded-bl-lg rounded-br-lg">
<Button
variant="accent"
size="sm"
startIcon={{ icon: Plus }}
on:click={() => {
variableEditor?.initNew?.()
}}
>
New variable
</Button>
</div>
{/snippet}
</ItemPicker>
<VariableEditor bind:this={variableEditor} workspace={ws} />
<InputTransformPickers {args} {pickForField} {workspace} bind:itemPicker bind:variableEditor />
@@ -14,6 +14,7 @@
import { getResourceTypes } from './resourceTypesStore'
import { twMerge } from 'tailwind-merge'
import { workspaceStore } from '$lib/stores'
import { AGENT_FIELDS, initialVisibleAgentFields } from './flows/agentFormFields'
interface Props {
schema: Schema | { properties?: Record<string, any>; required?: string[] }
@@ -43,15 +44,45 @@
isValid = allTrue(inputCheck) ?? false
})
/** An agent asks for the same fields here that its own form shows: a setting the step leaves
* unset is not something a run needs told, and listing all eleven buries the message under the
* configuration. What the step configures stays, as it does on any other step. A schema key the
* field registry doesn't know is kept, so a new one is never silently dropped. A run input is
* kept whatever the step holds: this form has no add-field control, so hiding one would leave
* no way at all to supply it. */
let schemaKeys = $derived(Object.keys(schema?.properties ?? {}))
let visibleKeys = $derived.by(() => {
const all = schemaKeys
if ((mod.value as { type?: string })?.type !== 'aiagent') return all
const transforms = (mod.value as { input_transforms?: Record<string, unknown> })
?.input_transforms
const visible = initialVisibleAgentFields(transforms, schema?.properties)
const known = new Set(AGENT_FIELDS.filter((f) => !f.runInput).map((f) => f.key))
return all.filter((key) => !known.has(key) || visible.has(key))
})
let keys: string[] = $state([])
$effect(() => {
let lkeys = Object.keys(schema?.properties ?? {})
let lkeys = visibleKeys
if (schema?.properties && JSON.stringify(lkeys) != JSON.stringify(keys)) {
keys = lkeys
untrack(() => stepsInputArgs?.removeExtraKey(mod.id, keys))
// Pruned against the schema rather than against what is shown. What a run was given for a
// field lives only here, so dropping it when the field merely stops being displayed would
// discard it: an agent hides the settings its step leaves unset, and clearing one in the
// Inputs tab hides it.
untrack(() => stepsInputArgs?.removeExtraKey(mod.id, schemaKeys))
}
})
/** Whether re-evaluating has anything to restore. A field the step configures nothing for
* evaluates to blank, so the control would only clear what was typed to run with. */
function hasConfiguredInput(argName: string): boolean {
const transform = (mod.value as any)?.input_transforms?.[argName]
if (!transform) return false
return transform.type === 'javascript' ? !!transform.expr : transform.value !== undefined
}
function plugIt(argName: string) {
stepsInputArgs?.setEvaluatedStepArg(
mod.id,
@@ -158,7 +189,7 @@
workspace={opWs}
>
{#snippet fieldHeaderActions()}
{#if stepsInputArgs?.isArgManuallySet(mod.id, argName)}
{#if stepsInputArgs?.isArgManuallySet(mod.id, argName) && hasConfiguredInput(argName)}
<Button
on:click={() => {
plugIt(argName)
+30 -14
View File
@@ -3,6 +3,7 @@
ScriptService,
type AiAgent,
type FlowModule,
type InputTransform,
type JavascriptTransform,
type Job
} from '$lib/gen'
@@ -43,6 +44,8 @@
opWorkspace
} = getContext<FlowEditorContext>('FlowEditorContext')
let previewBase = $derived($pathStore ?? '')
// Acting workspace when the flow editor runs in an AI session; else the nav workspace.
let opWs = $derived(opWorkspace?.() ?? $workspaceStore)
@@ -96,7 +99,10 @@
}
if (val.type == 'rawscript') {
await jobLoader?.runPreview(
val.path ?? ($pathStore ?? '') + '/' + mod.id,
// An empty base stays empty: `'' + '/' + id` is an absolute path, which
// `require_path_read_access_for_preview` rejects outright. A flow with no path yet
// previews unnamed instead.
val.path ?? (previewBase ? previewBase + '/' + mod.id : ''),
val.content,
val.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
@@ -104,7 +110,7 @@
undefined,
undefined,
callbacks,
$pathStore,
previewBase,
undefined,
devTempScriptRefs?.(),
timeout
@@ -122,7 +128,7 @@
script.lock,
val.hash ?? script.hash,
callbacks,
$pathStore,
previewBase,
undefined,
undefined,
timeout
@@ -132,18 +138,28 @@
} else if (val.type == 'aiagent') {
const { schema } = await loadSchemaFromModule(mod, opWs)
const inputTransforms: { [key: string]: JavascriptTransform } = Object.fromEntries(
Object.keys(args).map((key) => [
key,
{
expr: `flow_input.${key}`,
type: 'javascript'
}
])
)
const agentVal = val
// The test form only covers the schema it was given, and for a standalone agent that may be
// the flow-local one (the agent editor shows the brain in its own form, not here). Take the
// brain from the module as authored and let the form's own keys win over it, so an edit made
// in the form after the test panel mounted is what runs. A linked agent needs none of this:
// the server reads its brain from the resource.
const inputTransforms: { [key: string]: JavascriptTransform | InputTransform } = {
...(agentVal.agent
? {}
: ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>)),
...Object.fromEntries(
Object.keys(args).map((key) => [
key,
{
expr: `flow_input.${key}`,
type: 'javascript'
}
])
)
}
await jobLoader?.runFlowPreview(
args,
{
@@ -168,7 +184,7 @@
schema
},
callbacks,
$pathStore
previewBase
)
} else {
throw Error('Not supported module type')
@@ -24,6 +24,9 @@
defaultValues?: Record<string, any> | undefined
workspace?: string | undefined
selected?: string | undefined
/** Show the value as JSON rather than as the resource type's form. Bindable so a caller can
* choose the view a given resource opens on, and the in-form toggle still works. */
viewJsonSchema?: boolean
/** Notifies the parent drawer whether a local draft for the selected
* workspace diverges from the deployed baseline, so it can show the
* "unsaved changes" banner below its header. */
@@ -43,6 +46,7 @@
defaultValues = undefined,
workspace = undefined,
selected: selectedProp = $bindable(),
viewJsonSchema = $bindable(),
onDraftStateChange,
onCanWriteChange
}: Props = $props()
@@ -106,7 +110,6 @@
let isValid = $state(true)
let jsonError = $state('')
let viewJsonSchema = $state(false)
let perWsValid: Record<string, boolean> = $state({})
const deployToResource = resource(
@@ -395,7 +398,7 @@
bind:args={current.args}
bind:wsSpecific={current.wsSpecific}
bind:isValid
bind:viewJsonSchema
bind:viewJsonSchema={() => viewJsonSchema ?? false, (v) => (viewJsonSchema = v)}
bind:jsonError
{initialPath}
{hidePath}
@@ -52,6 +52,7 @@
let path: string | undefined = $state(undefined)
let selected: string | undefined = $state(undefined)
let viewJsonSchema = $state(false)
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
// The editor renders whichever workspace-specific variant `selected` points at, so history has
@@ -65,10 +66,29 @@
historyWorkspace === $workspaceStore && isOwner(path ?? '', $userStore, $workspaceStore)
)
export async function initEdit(p: string): Promise<void> {
// A close reaches `on:close` on a later flush, by which point a caller that closed this drawer to
// open another editor has already anchored the new one. Clearing then would strip that anchor.
let keepAnchorOnClose = false
/** Shut this drawer without going through its own close button, for a caller opening the other
* editor over the same list. `keepAnchor` when that caller anchors what it opens instead. */
export function close(opts?: { keepAnchor?: boolean }): void {
keepAnchorOnClose = opts?.keepAnchor ?? false
drawer?.closeDrawer?.()
}
/** `json` opens on the JSON editor instead of the resource type's form. For a type with a
* dedicated editor elsewhere: the generic form would render its configuration field by field,
* and materialize a default into every one the value leaves out. */
export async function initEdit(p: string, opts?: { json?: boolean }): Promise<void> {
// A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag
// would still be standing when the next drawer session ends and would swallow that one's
// anchor clear. Every session starts having to clear its own.
keepAnchorOnClose = false
resource_type = undefined
path = p
selected = effectiveWorkspace
viewJsonSchema = opts?.json ?? false
drawer?.openDrawer?.()
setPageDrawerAnchor(RESOURCES_PATH, p)
}
@@ -77,10 +97,15 @@
resourceType: string,
nDefaultValues?: Record<string, any>
): Promise<void> {
keepAnchorOnClose = false
path = undefined
resource_type = resourceType
defaultValues = nDefaultValues
selected = effectiveWorkspace
// This drawer outlives what it opens on, so the view has to be set by every entry point
// rather than left where the last one put it: a new resource is a typed form, whoever was
// looking at JSON before.
viewJsonSchema = false
drawer?.openDrawer?.()
}
@@ -97,7 +122,13 @@
bind:this={drawer}
size="50rem"
{disableChatOffset}
on:close={() => clearPageDrawerAnchor(RESOURCES_PATH)}
on:close={() => {
if (keepAnchorOnClose) {
keepAnchorOnClose = false
return
}
clearPageDrawerAnchor(RESOURCES_PATH)
}}
>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
@@ -121,6 +152,7 @@
bind:this={resourceEditor}
bind:canSave
bind:selected
bind:viewJsonSchema
onDraftStateChange={(v) => (hasLocalDraft = v)}
onCanWriteChange={(v) => (canWriteSelected = v)}
/>
@@ -33,6 +33,10 @@
datatableAsPgResource?: boolean
workspace?: string | undefined
disableChatOffset?: boolean
/** Fires when this picker sets a resource, with the type it carries, and with `undefined` on
* clear. Unlike an effect on `valueType` it never fires for the value the picker was opened
* on, but `selectFirst` choosing the only candidate during a load does count as setting one. */
onValueChange?: (path: string | undefined, type: string | undefined) => void
}
let {
@@ -54,7 +58,8 @@
excludedValues = undefined,
datatableAsPgResource = false,
workspace = undefined,
disableChatOffset = false
disableChatOffset = false,
onValueChange = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
@@ -155,6 +160,7 @@
if (collection.length == 1 && selectFirst && (value == undefined || value == '')) {
value = collection[0].value
valueType = collection[0].type
onValueChange?.(value, valueType)
}
} catch (e) {
sendUserToast('Failed to load resource types', true)
@@ -196,6 +202,7 @@
await loadResources(resourceType)
value = e.detail
valueType = collection.find((x) => x?.value == value)?.type
onValueChange?.(value, valueType)
}}
bind:this={appConnect}
{expressOAuthSetup}
@@ -211,6 +218,7 @@
if (e.detail) {
value = e.detail
valueType = collection.find((x) => x?.value == value)?.type
onValueChange?.(value, valueType)
// valueSelect = { value: e.detail, label: e.detail, type: valueType ?? '' }
}
}}
@@ -237,12 +245,14 @@
(v) => {
value = v
valueType = collection.find((x) => x?.value == v)?.type
onValueChange?.(value, valueType)
}
}
onClear={() => {
initialValue = undefined
value = undefined
valueType = undefined
onValueChange?.(undefined, undefined)
onClear?.()
}}
items={collection}
@@ -281,6 +291,7 @@
<div class="flex bg-surface border-t divide-x">
{#if resourceType?.includes(',')}
<DropdownV2
class="flex-1 justify-stretch"
enableFlyTransition
items={resourceType?.split(',').map((rt) => ({
displayName: `${rt} resource`,
@@ -294,7 +305,7 @@
color="light"
variant="contained"
wrapperClasses="flex-1"
btnClasses="rounded-none mt-0.5"
btnClasses="rounded-none"
size="sm"
startIcon={{ icon: Plus }}
>
@@ -392,6 +392,10 @@
fontSize?: number
loadAsync?: boolean
class?: string | undefined
/** Height the editor opens at, in lines, for a field that expects more than a phrase. */
minRows?: number
/** Shown over the empty editor. Monaco has no placeholder of its own. */
placeholder?: string
}
let {
@@ -403,13 +407,25 @@
fixedOverflowWidgets = true,
fontSize,
loadAsync = false,
class: clazz = ''
class: clazz = '',
minRows = undefined,
placeholder = undefined
}: Props = $props()
let effectiveFontSize = $derived(fontSize ?? editorFontSize.regular)
let yPadding = MONACO_Y_PADDING
// Monaco derives an unset line height as 1.5x the font size, rounded. Reproducing it here lets
// the box stand at its final height before the editor exists, so opening a field does not jump.
let minHeightPx = $derived(
minRows ? minRows * Math.round(1.5 * effectiveFontSize) + yPadding * 2 : 0
)
/** Whether the live model is empty, for the placeholder. Tracked apart from `code`, which the
* editor only writes back on a debounce. */
let editorEmpty = $state(true)
if (typeof code != 'string') {
code = ''
}
@@ -526,6 +542,9 @@
}
editor.onDidChangeModelContent((event) => {
// Undebounced, unlike `code`: the placeholder sits over the editor, so waiting would leave
// it covering the first characters typed into an empty field.
editorEmpty = (editor?.getModel()?.getValue() ?? '') === ''
timeoutModel && clearTimeout(timeoutModel)
timeoutModel = setTimeout(() => {
updateCode()
@@ -538,7 +557,7 @@
const updateHeight = () => {
const contentHeight = Math.min(1000, editor?.getContentHeight() ?? 0)
if (divEl) {
divEl.style.height = `${contentHeight}px`
divEl.style.height = `${Math.max(contentHeight, minHeightPx)}px`
}
}
editor.onDidContentSizeChange(updateHeight)
@@ -709,16 +728,31 @@
<EditorTheme />
<div
class={twMerge(inputBorderClass({ forceFocus: isFocus }), 'rounded-md overflow-auto pl-2', clazz)}
class={twMerge(
inputBorderClass({ forceFocus: isFocus }),
'relative rounded-md overflow-auto pl-2',
clazz
)}
style={minHeightPx ? `min-height: ${minHeightPx}px` : undefined}
>
{#if !editor}
<FakeMonacoPlaceHolder autoheight showNumbers={false} {code} fontSize={effectiveFontSize} />
{/if}
<div
bind:this={divEl}
style="height: 18px;"
style="height: {Math.max(18, minHeightPx)}px;"
class="template nonmain-editor rounded-md overflow-clip {!editor ? 'hidden' : ''}"
></div>
{#if placeholder && !code && (!editor || editorEmpty)}
<div
class="absolute inset-0 px-2 pointer-events-none whitespace-pre-wrap text-hint font-mono"
style="font-size: {effectiveFontSize}px; line-height: {Math.round(
1.5 * effectiveFontSize
)}px; padding-top: {yPadding}px;"
>
{placeholder}
</div>
{/if}
</div>
<style>
@@ -1,64 +0,0 @@
<script lang="ts">
import Modal from '$lib/components/common/modal/Modal.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import type { AgentDraft } from '$lib/gen'
import type { EvalsLocation } from './evalUtils'
import EvalsPane from './EvalsPane.svelte'
let {
agentPath = undefined,
open = $bindable(),
opWorkspace = undefined,
editedConfig = undefined
}: {
/** The agent under test. A dataset and its runs belong to a saved agent. */
agentPath?: string
open: boolean
/** The workspace the opening editor operates on, which differs from the nav workspace in
* fork and session editors. Every read and write targets it. */
opWorkspace?: string
/** Opened from an agent being edited: the edits, as the step holds them, are what a run is
* offered on. Everywhere else the agent is what is deployed. */
editedConfig?: () => AgentDraft
} = $props()
const TITLE = 'Evals'
let location = $state<EvalsLocation | undefined>(undefined)
let trail = $derived(
location ? [{ label: TITLE, onclick: location.back }, { label: location.label }] : undefined
)
</script>
<!-- The `sm:` widths are what actually win: the dialog's own are breakpoint-prefixed. -->
<Modal
title={TITLE}
{trail}
paginated
enterConfirms={false}
bind:open
kind="X"
class="w-[90vw] sm:w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[85vh]"
fillHeight
>
{#snippet titleBadge()}
<Badge color="blue" small class="shrink-0 !py-0 leading-4">Beta</Badge>
{/snippet}
<div class="h-full min-h-0">
{#if agentPath}
<!-- Keyed: the resources page keeps one dialog for every row, so a different agent must
start from nothing rather than inherit the runs and the open run of the last one. -->
{#key `${opWorkspace ?? ''}:${agentPath}`}
<EvalsPane {agentPath} {opWorkspace} {editedConfig} bind:location />
{/key}
{:else}
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
<span class="text-sm text-emphasis">Evals run against a saved agent</span>
<span class="text-xs text-secondary max-w-md">
This agent is written into the flow step rather than saved as its own agent, so there is
nothing for a dataset and its runs to belong to. Save it as a reusable agent from the
step, and its evals start there.
</span>
</div>
{/if}
</div>
</Modal>
@@ -64,7 +64,8 @@
agentPath,
opWorkspace = undefined,
editedConfig = undefined,
location = $bindable()
location = $bindable(),
active = true
}: {
/** The agent under test. A dataset and its runs belong to an agent. */
agentPath: string
@@ -77,6 +78,10 @@
/** The level the pane is on and the way out of it, reported up so the surface holding it
* can put both in its header. Undefined at the root, which that surface already names. */
location?: EvalsLocation
/** False while the pane is parked off screen by a surface that keeps it mounted. Its own
* pages answer the arrow keys at `window`, which a parked instance would take from
* whatever is actually on screen. */
active?: boolean
} = $props()
let ws = $derived(opWorkspace ?? $workspaceStore)
@@ -666,19 +671,21 @@
warm
class="grow min-h-0"
current={!viewingRun || !loaded ? 'list' : 'run'}
onNavigate={(key) => {
// Right opens the run under the highlight, falling back to whichever was open before;
// left is the way back, the same as the breadcrumb.
if (key === 'run') {
// Both branches go through `openRun`: it is what brings the run's own dataset back,
// and the fallback run may be of a dataset the list has since moved off.
const id = highlightedRunId ?? experimentId
if (id) openRun(id)
} else if (key === 'list') {
viewingRun = false
selectedCaseId = undefined
}
}}
onNavigate={!active
? undefined
: (key) => {
// Right opens the run under the highlight, falling back to whichever was open before;
// left is the way back, the same as the breadcrumb.
if (key === 'run') {
// Both branches go through `openRun`: it is what brings the run's own dataset back,
// and the fallback run may be of a dataset the list has since moved off.
const id = highlightedRunId ?? experimentId
if (id) openRun(id)
} else if (key === 'list') {
viewingRun = false
selectedCaseId = undefined
}
}}
pages={[
{ key: 'list', content: listPage },
{ key: 'run', content: runPage }
@@ -727,7 +734,7 @@
{datasets}
{caseProgress}
{loaded}
active={!viewingRun}
active={active && !viewingRun}
{deployedHash}
{currentVersion}
onOpen={(e) => openRun(e.id)}
@@ -35,9 +35,11 @@
style?: string
cancelText?: string | undefined
kind?: 'button' | 'X'
/** Where you are inside the dialog, as a breadcrumb replacing the title: the whole path,
* the dialog's own root first. A dialog at its root passes nothing (or one level) and keeps
* its plain title; ancestors with an `onclick` are the way back, which Escape also takes. */
/** Where you are inside the dialog: the whole path, the dialog's own root first. Below the
* root the last level becomes the heading and the ones above it the line under it, with a
* back control for the nearest. A dialog at its root passes nothing (or one level) and
* keeps its plain title; ancestors with an `onclick` are the way back, which Escape also
* takes. */
trail?: ModalTrailSegment[]
/** A line under the title saying what the dialog is for; in the header so it does not
* scroll away with the body. */
@@ -63,9 +65,12 @@
* side panel when it is open. Pass an explicit value to stack above other
* surfaces (e.g. a modal opened over the /sessions preview-pane editor). */
minZIndex?: number
/** Rendered against the dialog's own name, before any level below it: what it marks is the
* dialog rather than wherever in it you have navigated to. */
/** Rendered against the dialog's own name, wherever that name is: the heading at the root,
* the first level of the way back below it. */
titleBadge?: import('svelte').Snippet
/** Rendered against the level you are on, which below the root is the heading. Nothing at
* the root, where that level is the dialog and `titleBadge` already names it. */
levelBadge?: import('svelte').Snippet
settings?: import('svelte').Snippet
children?: import('svelte').Snippet
actions?: import('svelte').Snippet
@@ -85,6 +90,7 @@
enterConfirms = true,
minZIndex: minZIndexProp = undefined,
titleBadge,
levelBadge,
settings,
children: children_render,
actions
@@ -101,7 +107,9 @@
// A trail of one level is the dialog at its root, which the plain title already shows.
const crumbs = $derived(trail && trail.length > 1 ? trail : undefined)
// The level under the one you are on: what Escape and the back chevron return to.
// The level you are on, which is the heading below the root.
const current = $derived(crumbs?.[crumbs.length - 1])
// The level under it: what Escape and the back control return to.
const back = $derived(crumbs?.[crumbs.length - 2])
const dispatch = createEventDispatcher()
@@ -164,22 +172,19 @@
<svelte:window onkeydowncapture={onKeyDown} />
<!-- The level you would return to wears the back chevron; there is no separate back control. -->
{#snippet crumb(segment: ModalTrailSegment, isBack: boolean)}
<Button
variant="subtle"
unifiedSize="sm"
title={isBack ? `Back to ${segment.label}` : undefined}
onClick={segment.onclick}
wrapperClasses="min-w-0 shrink"
btnClasses="group !px-0 !font-normal text-secondary hover:text-emphasis hover:!bg-transparent gap-0.5 min-w-0"
<!-- The heading names the level you are on; where it sits within the dialog goes under it. -->
<!-- `oneLine` for a level of the trail, which shares its row with the way back and the actions.
The dialog's own title wraps instead, as it did before there was a trail: it is the whole row,
and titles carrying a path or a sentence are long by nature. -->
{#snippet heading(label: string, badge: import('svelte').Snippet | undefined, oneLine: boolean)}
<h3
class="text-emphasis text-lg font-semibold leading-7 flex items-center gap-1 {oneLine
? 'min-w-0'
: ''}"
>
{#if isBack}
<!-- Pulled left so the label stays about where the title sits at the root. -->
<ChevronLeft size={18} class="shrink-0 -ml-1" />
{/if}
<span class="truncate group-hover:underline">{segment.label}</span>
</Button>
<span class={oneLine ? 'truncate' : ''}>{label}</span>
{@render badge?.()}
</h3>
{/snippet}
<Disposable bind:open bind:this={disposable} preventEscape {minZIndex}>
@@ -234,72 +239,69 @@
<!-- min-w-0: without it this flex item takes its content's min-content width and
stretches the modal past its max-width instead of letting content shrink. -->
<div class="text-left flex-1 min-w-0 {fillHeight ? 'flex flex-col min-h-0' : ''}">
{#if crumbs}
<!-- pr-8 under `kind="X"`: the close button is absolutely positioned, so a
long trail would otherwise run under it. -->
<div
class="flex flex-row items-center justify-between gap-2 min-w-0 {kind ===
'X'
? 'pr-8'
: ''}"
>
<!-- leading-7 on the row and the heading alike, so the header is the same
height at the root as one level in. -->
<nav
aria-label="Breadcrumb"
class="flex flex-row items-center gap-1 min-w-0 text-lg font-semibold leading-7"
>
{#each crumbs as segment, i (i)}
{#if i === 1}
{@render titleBadge?.()}
{/if}
{#if i > 0}
<span class="flex shrink-0" in:fade={{ duration: 150 }}>
<ChevronRight size={18} class="text-tertiary shrink-0" />
</span>
{/if}
{#if i === 0}
<!-- flex: an inline child holding an icon sits on the baseline and adds
descender room, which would make the row taller than at the root. -->
<h3
class="shrink-0 leading-7 flex items-center {segment.onclick
? ''
: 'text-emphasis'}"
>
{#if segment.onclick}
{@render crumb(segment, i === crumbs.length - 2)}
{:else}
{segment.label}
<!-- pr-8 under `kind="X"`: the close button is absolutely positioned, so a long
heading or the header actions would otherwise run under it. -->
<div
class="flex flex-row items-start justify-between gap-2 min-w-0 {kind === 'X'
? 'pr-8'
: ''}"
>
{#if crumbs && current}
<div class="flex flex-row items-start gap-1 min-w-0">
{#if back?.onclick}
<!-- Pulled left so the heading lands about where the title sits at the
root, and up so it reads against that heading rather than against
the two lines together. -->
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: ChevronLeft }}
title="Back to {back.label}"
onClick={back.onclick}
wrapperClasses="shrink-0 -ml-1 -mt-0.5"
/>
{/if}
<div class="flex flex-col min-w-0">
{@render heading(current.label, levelBadge, true)}
<!-- The way back in full: the control above carries one level of it. -->
<nav
aria-label="Breadcrumb"
class="flex flex-row items-center gap-0.5 min-w-0 text-xs text-secondary"
>
{#each crumbs.slice(0, -1) as segment, i (i)}
{#if i > 0}
<span class="flex shrink-0" in:fade={{ duration: 150 }}>
<ChevronRight size={12} class="text-tertiary shrink-0" />
</span>
{/if}
</h3>
{:else if segment.onclick}
{@render crumb(segment, i === crumbs.length - 2)}
{:else}
<span
class="text-emphasis truncate"
aria-current="page"
in:fade={{ duration: 150 }}
>
{segment.label}
</span>
{/if}
{/each}
</nav>
{@render settings?.()}
</div>
{:else}
<div class="flex flex-row items-center justify-between">
<h3
class="text-emphasis text-lg font-semibold {titleBadge
? 'flex items-center gap-1'
: ''}"
>
{title}
{@render titleBadge?.()}
</h3>
{@render settings?.()}
</div>
{/if}
{#if segment.onclick}
<Button
variant="subtle"
unifiedSize="2xs"
onClick={segment.onclick}
wrapperClasses="min-w-0 shrink"
btnClasses="!px-0 !font-normal !text-xs text-secondary hover:text-emphasis hover:underline hover:!bg-transparent min-w-0"
>
<span class="truncate">{segment.label}</span>
</Button>
{:else}
<span class="truncate" in:fade={{ duration: 150 }}
>{segment.label}</span
>
{/if}
{#if i === 0}
{@render titleBadge?.()}
{/if}
{/each}
</nav>
</div>
</div>
{:else}
{@render heading(title, titleBadge, false)}
{/if}
{@render settings?.()}
</div>
{#if description}
<p class="mt-1 text-xs text-secondary">{description}</p>
@@ -55,12 +55,13 @@ export function overlayStack(): OverlayStack {
* Reads context, so call it during component initialisation; call the returned getter
* where the target is used, to stay reactive as the host element mounts.
*/
export function overlayPortalTarget(fallback: string): () => HTMLElement | string {
export function overlayPortalTarget(fallback: string | (() => string)): () => HTMLElement | string {
const host = getOverlayHost()
return () => {
const selector = typeof fallback === 'function' ? fallback() : fallback
const el = host?.el()
if (!el) return fallback
return el.querySelector<HTMLElement>(fallback) ?? el
if (!el) return selector
return el.querySelector<HTMLElement>(selector) ?? el
}
}
@@ -165,7 +165,7 @@
<Modal bind:open={expanded} title="Diagram" kind="X" fillHeight class="sm:max-w-none w-[92vw]">
{#snippet settings()}
<div class="flex flex-row gap-1 mr-8">
<div class="flex flex-row gap-1">
<Button
onclick={() => zoomBy(1 / 1.3)}
color="light"
@@ -2,6 +2,8 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import Disposable from '$lib/components/common/drawer/Disposable.svelte'
import FlowEditorPanel from './content/FlowEditorPanel.svelte'
import { agentEditorTarget, type AgentEditorTarget } from './agentEditorStore.svelte'
import AgentEditorModal from './content/AgentEditorModal.svelte'
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
import WindmillIcon from '../icons/WindmillIcon.svelte'
@@ -14,7 +16,7 @@
import { useFlowPanelMode } from './flowPanelMode.svelte'
import { useFlowPanelPlacementTelemetry } from './flowEditorTelemetry'
import { writable } from 'svelte/store'
import { get, writable } from 'svelte/store'
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
import type { PickableProperties } from '$lib/components/flows/previousResults'
import type { Flow, Job } from '$lib/gen'
@@ -37,8 +39,16 @@
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
import { userStore } from '$lib/stores'
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
import { userStore, workspaceStore } from '$lib/stores'
const { flowStore, selectionManager, pathStore, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
// Flow paths repeat across workspaces, and a session keeps every tab it has visited alive, so two
// editors can hold the same path at once. Both halves are needed to tell them apart.
let editorWorkspace = $derived(opWorkspace?.() ?? $workspaceStore)
function targetWorkspace(t: AgentEditorTarget): string | undefined {
return t.workspace ?? $workspaceStore
}
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
@@ -231,7 +241,19 @@
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig,
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined),
inModalPanel: () => panelMode === 'modal'
// The agent editor is a dialog over the same graph, so a connect started inside it has the
// same closure hazard as one started from the modal panel. Only this flow's own, on the same
// rule the mount below claims one by: a session keeps every visited tab alive, and a target
// belonging to another of them is not a dialog over this graph.
inModalPanel: () => {
if (panelMode === 'modal') return true
const t = agentEditorTarget()
return (
t !== undefined &&
t.host?.flowPath === get(pathStore) &&
targetWorkspace(t) === editorWorkspace
)
}
})
// Read by graph step items (VirtualItem) to show a per-step "explore" hint on hover,
@@ -524,3 +546,12 @@
{/if}
{/snippet}
</Disposable>
<!-- Mounted here rather than in the panel, which is keyed on the selection and would take the
dialog down with it the moment the graph selection moved. Claims only agents opened from this
flow: a session keeps every visited tab alive, and each would otherwise build its own editor
over the same draft. -->
<AgentEditorModal
enableAi={!disableAi}
owns={(t) => t.host?.flowPath === $pathStore && targetWorkspace(t) === editorWorkspace}
/>
@@ -0,0 +1,363 @@
import { untrack } from 'svelte'
import { get } from 'svelte/store'
import { deepEqual } from 'fast-equals'
import { ResourceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { canWrite } from '$lib/utils'
import { userStore } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { useTriggerDraftSync, type TriggerDraftSync } from '../triggers/useTriggerDraftSync.svelte'
import { logReusableAgentUsage } from './agentTelemetry'
import {
AGENT_BRAIN_LABELS,
agentEditorRefusal,
transformValuedBrainKeys,
type AIAgentConfig
} from './agentResourceUtils'
// Lives here rather than beside the other config helpers: `agentResourceUtils` is a leaf, and
// importing tool-name validation into it would cycle back through `flowInfers` and pull the whole
// editor into every module that reads a brain key.
import { getToolNameError } from './agentToolUtils'
/**
* Why a run of this config would fail before it started, if it would. Only the conditions the
* worker itself rejects: a provider it cannot call, or a tool name it cannot put in the schema it
* gives the model. Everything else an agent may legitimately leave unset.
*/
function agentConfigRunError(args: Record<string, any> | undefined): string | undefined {
const provider = args?.provider
if (!provider?.resource || !provider?.model) {
return 'Select a provider resource and model before deploying.'
}
// `kind` is what the worker deserializes the provider as, and a config written through the JSON
// editor can leave it out entirely. Presence only: the list of kinds lives behind the provider
// SDKs, and an unrecognised one already fails the same way at the worker.
if (typeof provider.kind !== 'string' || provider.kind === '') {
return 'The provider is missing its kind. Pick the resource again to set it.'
}
// Reported rather than iterated: the editor renders a JSON-authored non-list as an empty
// roster, so this is the only place left that would meet the raw value, and a bare `.filter`
// on it throws past the caller. `null` is how the API spells "unset".
if (args?.tools != null && !Array.isArray(args.tools)) {
return 'Tools must be a list. Fix it in the resource editor before deploying.'
}
// Only a flowmodule tool's summary is a callable name: the worker never reads an mcp or
// websearch summary, so a blank one there is not an error and must not count as a sibling.
// Same filter as `collectInvalidAgentToolNames`, which is the rule the graph enforces.
const tools = (args?.tools ?? []) as Record<string, any>[]
const named = tools.filter(
(t) => t?.value?.tool_type !== 'mcp' && t?.value?.tool_type !== 'websearch'
)
const siblingNames = named.map((t) => t?.summary ?? '')
for (const tool of named) {
const err = getToolNameError(tool?.summary ?? '', tool?.value?.tool_type, siblingNames)
if (err) return `${err}. Name every tool before deploying.`
}
// An MCP tool is named by its server rather than by a summary, and it starts with none. The
// worker loads every MCP config before the model runs, so one unresolved path fails every run
// of the agent rather than just the call that needed it.
if (tools.some((t) => t?.value?.tool_type === 'mcp' && !t?.value?.resource_path)) {
return 'Pick a server for every MCP tool before deploying.'
}
return undefined
}
/**
* The draft shape every resource editor writes, and the only one the review/deploy page knows how
* to canonicalize and deploy (`utils_draft_deploy.ts`, `canonicalizeDraftDiffValue` /
* `deployDraft`). The agent editor is a different *form* over this same row, never a parallel
* draft: writing anything else here would take agent drafts off the deploy page and out of the
* generic editor.
*
* `args` is the resource value. `description`, `labels` and `wsSpecific` are round-tripped
* untouched the agent form has no place for them, and dropping them would rewrite the resource
* on deploy.
*/
export interface AgentResourceState {
path: string
description: string
args: AIAgentConfig
/** Present only for a path with no deployed row, whose type the draft is the sole record of.
* Elsewhere it is left out to match the generic resource editor, which has no field for it. */
resource_type?: string
labels?: string[]
wsSpecific: boolean
}
export interface AgentDraftOptions {
/** The `ai_agent` resource being edited. */
path: () => string | undefined
workspace: () => string | undefined
}
export interface AgentDraftHandle {
/** The form's live state, draft included. Bind the editor to `state.args`. */
readonly state: AgentResourceState | undefined
/** The deployed baseline, for the banner's diff. */
readonly deployed: AgentResourceState | undefined
readonly loading: boolean
/** No deployed row at this path, so a deploy has to create rather than update. */
readonly noDeployed: boolean
/** Whether this user may write the resource. False makes the editor a read-only view. */
readonly canWrite: boolean
/** Why this path cannot be edited here, if it cannot. Render it instead of the form. */
readonly refusal: string | undefined
readonly sync: TriggerDraftSync
/** Write the current state to the resource and drop the draft. */
deploy: () => Promise<boolean>
}
/**
* Local-autosave wiring for an `ai_agent` resource, over the shared drawer-kind draft sync. Holding
* a live handle is what makes a write from elsewhere another tab, the generic resource editor on
* the same path land in the open agent editor.
*/
export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
let state = $state<AgentResourceState | undefined>(undefined)
let deployed = $state<AgentResourceState | undefined>(undefined)
let loading = $state(true)
let noDeployed = $state(false)
let canWriteResource = $state(true)
/** Guards the load against a path that changed under a slow response. */
let loadedFor = $state<string | undefined>(undefined)
/** Why the loaded path cannot be edited here, if it cannot. Gates the sync for as long as that
* path stays, so a resource this editor refuses can neither restore its draft into the form nor
* have one autosaved from it. Set it through `refuse`, which also clears what was loaded. */
let refusal = $state<string | undefined>(undefined)
const sync = useTriggerDraftSync({
itemKind: 'resource',
path: () => opts.path() ?? '',
workspace: () => opts.workspace(),
drawerLoading: () => loading || refusal != null,
// `$state.snapshot` deep-reads, so the sync effects re-run when a nested field of `args`
// changes. Returning the object itself only subscribes to the reference, and every edit the
// form makes is a nested one.
getCfg: () => (state ? ($state.snapshot(state) as Record<string, any>) : undefined),
applyCfg: (cfg) => {
state = cfg as AgentResourceState
},
deployed: () => deployed as Record<string, any> | undefined
})
function refuse(reason: string) {
loading = false
refusal = reason
state = undefined
deployed = undefined
noDeployed = false
canWriteResource = false
sendUserToast(reason, true)
}
$effect(() => {
const path = opts.path()
const ws = opts.workspace()
untrack(() => {
if (!path || !ws) {
state = undefined
deployed = undefined
loadedFor = undefined
refusal = undefined
return
}
const key = `${ws}:${path}`
if (loadedFor === key) return
loadedFor = key
loading = true
refusal = undefined
// The user alongside the resource, as the generic resource editor loads it: a session or
// fork editor operates on a workspace that is not the one being navigated, and groups,
// folders and the admin flag are all per workspace, so the nav user would answer for the
// wrong membership in both directions.
Promise.all([
ResourceService.getResource({ workspace: ws, path, getDraft: true }),
getUserExt(ws).catch(() => undefined)
])
// The rejection handler is `then`'s second argument rather than a trailing `catch`, so
// it answers for the fetch alone: a throw in the body below leaves a loaded editor
// standing instead of tearing it down as an unreadable resource.
.then(
async ([r, user]) => {
// A slower response for a path we have left must not overwrite the current one.
if (loadedFor !== key) return
// A step's `agent` is caller-authored, so it can name a resource of any type, and a
// deploy from here would replace that resource's whole value while keeping its type.
const refused = agentEditorRefusal(path, r.resource_type)
if (refused) {
refuse(refused)
return
}
noDeployed = Boolean((r as any).no_deployed)
const deployedState: AgentResourceState = {
path: r.path,
description: r.description ?? '',
args: (r.value ?? {}) as AIAgentConfig,
// Only where nothing else answers for the type: with a deployed row both the
// create below and the review page's `deployDraft` read it from there, and
// carrying it would make every draft the generic resource editor writes — which
// has no field for it — differ from this baseline on that key alone.
...(noDeployed ? { resource_type: r.resource_type } : {}),
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false
}
// Same rule the generic resource editor applies. The backend refuses the write
// either way, but without this the editor would invite edits it cannot save and
// autosave a draft on every keystroke against a resource the reader cannot deploy.
canWriteResource = canWrite(
r.path,
r.extra_perms ?? {},
user ?? get(userStore) ?? undefined
)
// An agent that exists only as a draft has no deployed value to compare against or
// fall back to, and the response's is a synthetic echo of the draft. Leaving the
// baseline unset is what suppresses the unsaved-changes banner for it, exactly as
// `useTriggerDraftSync` intends: its Discard would otherwise reset to that synthetic
// value and delete the one row the agent lives in.
deployed = noDeployed ? undefined : deployedState
// Open on the draft when there is one, so the editor never flashes the deployed
// config before the autosave lands.
state =
((r as any).draft as AgentResourceState | undefined) ?? structuredClone(deployedState)
loading = false
await sync.maybeRestore()
},
(err) => {
// A failed load knows neither the resource's type nor its value, so it refuses:
// clearing `loading` alone would let the sync restore a persisted draft into a form
// that would then deploy over a resource nobody read.
if (loadedFor !== key) return
refuse(`Could not load agent ${path}: ${err}`)
}
)
})
})
async function deploy(): Promise<boolean> {
const ws = opts.workspace()
const s = state
if (!ws || !s) return false
// The editor offers only values, but a transform can arrive from a step that was forked
// before this existed, or from the generic resource editor: say so rather than writing it.
const transformValued = transformValuedBrainKeys(s.args)
if (transformValued.length > 0) {
const fields = transformValued.map((key) => AGENT_BRAIN_LABELS[key] ?? key)
const many = fields.length > 1
sendUserToast(
`${fields.join(', ')} ${many ? 'are' : 'is'} set to an expression or an AI-filled value, which a saved agent cannot store. Replace ${many ? 'them' : 'it'} with a plain value before deploying.`,
true
)
return false
}
// The resource endpoint takes any JSON, so nothing downstream stops an agent that cannot run:
// the worker needs a provider to call and rejects a tool whose name it cannot pass to the
// model. Deploying one would break every flow linking it, so it is refused here.
const blocked = agentConfigRunError(s.args)
if (blocked) {
sendUserToast(blocked, true)
return false
}
// Renaming is not this editor's to do: moving the resource leaves every step that links to it
// naming a path that no longer exists, and reconciling those is a feature of its own. A
// renamed path can still reach here, the generic editor writing the same draft row and
// offering a path field, so refuse it rather than performing half of a rename.
const currentPath = opts.path()
if (currentPath && s.path !== currentPath) {
sendUserToast(
`This draft renames the agent to ${s.path}. Deploy it from the resource editor instead.`,
true
)
return false
}
// Only a draft naming another type: the load refuses a resource that is not an agent, while a
// draft the generic resource editor wrote names no type at all and inherits the loaded one.
if (s.resource_type && s.resource_type !== 'ai_agent') {
sendUserToast(`This draft is a ${s.resource_type} resource, not an agent.`, true)
return false
}
// The form stays editable while the request is in flight, so everything below works from a
// snapshot taken now. Adopting the live state as `deployed` afterwards would count an edit
// made during the request as saved, and the banner would clear on a value the server never
// received; against the snapshot it stays a draft, which is what it is.
const submitted = structuredClone($state.snapshot(s)) as AgentResourceState
const body = {
path: submitted.path,
value: submitted.args,
description: submitted.description,
labels: submitted.labels,
ws_specific: submitted.wsSpecific
}
try {
if (noDeployed) {
await ResourceService.createResource({
workspace: ws,
// A create needs a type, and the load proved this path is an agent before opening.
requestBody: { ...body, resource_type: submitted.resource_type ?? 'ai_agent' }
})
} else {
// The type this editor proved is as old as the load, and an update carries no type of
// its own: were the path deleted and recreated as something else meanwhile, this write
// would put an agent config inside that resource. Reading it again narrows the window
// to the request rather than to however long the editor stayed open.
const current = await ResourceService.getResource({ workspace: ws, path: submitted.path })
const refused = agentEditorRefusal(submitted.path, current.resource_type)
if (refused) {
refuse(refused)
return false
}
await ResourceService.updateResource({
workspace: ws,
path: submitted.path,
requestBody: body
})
}
} catch (err) {
sendUserToast(`Could not save agent: ${err}`, true)
return false
}
// The counter the step card's write-back used to report, from the surface that now owns the
// write: a deploy here reaches every flow linking this agent.
logReusableAgentUsage(noDeployed ? 'saved' : 'updated')
deployed = submitted
noDeployed = false
// Only when the form still holds exactly what was sent. `discard` resets the handle's cell to
// what it is given, and the apply-effect copies that back over the form: against an edit made
// while the request was in flight that would erase it, draft and all. Such an edit is a real
// unsaved change over the version just deployed, so it keeps its draft and its banner.
if (!deepEqual($state.snapshot(state), submitted)) {
sendUserToast(`Saved agent ${submitted.path}. Later edits are still unsaved`)
loadedFor = `${ws}:${submitted.path}`
return true
}
// `discard`, not `remove`: it resets the handle's cell to what was just saved, so the
// apply-effect cannot bounce the form back to the now-stale draft.
sync.discard(opts.path()!, submitted)
// A rename moves the row, so the next load must not reuse the old key.
loadedFor = `${ws}:${submitted.path}`
sendUserToast(`Saved agent ${submitted.path}`)
return true
}
return {
get state() {
return state
},
get deployed() {
return deployed
},
get loading() {
return loading
},
get noDeployed() {
return noDeployed
},
get canWrite() {
return canWriteResource
},
get refusal() {
return refusal
},
sync,
deploy
}
}
@@ -1,103 +0,0 @@
// The agent an AI agent step is being edited-in-place against (set by "Edit" on a linked agent),
// with what the editor needs to judge the edits. Lives outside AgentResourceBar so the "Editing
// <path>" mode survives that component unmounting — another node selected, or the step's own tab
// switched — while the session goes on.
//
// Entries are looked up by the forked step's `tools` array identity, not by any location key:
// Edit assigns a fresh array (unique per fork, including nested editors reusing a module id),
// in-place edits keep it, and wholesale state replacement (undo, session drafts) yields new
// objects that simply never match — a stale entry can't resurface as a phantom Editing banner.
import type { FlowModule } from '$lib/gen'
export type AgentEdit = {
/** The agent the fork is an edit of, which "Save changes" writes back to. */
path: string
/** The agent as deployed, in the form the editor compares edits in. Kept here rather than in
* the editor, which can unmount mid-session and would otherwise lose the baseline that tells
* an edit from the deployed value. */
deployedConfig?: string
}
type Entry = AgentEdit & { marker: object }
// Capped: abandoned forks (editor closed without Save/Cancel) leave dead entries behind.
const MAX_ENTRIES = 20
let entries = $state<Entry[]>([])
export function getAgentEdit(marker: object | undefined): AgentEdit | undefined {
const entry = marker ? entries.find((e) => e.marker === marker) : undefined
if (!entry) return undefined
// Without the marker: a caller that hands the edit back to `setAgentEditingPath` for another
// marker must not smuggle the old one along.
return { path: entry.path, deployedConfig: entry.deployedConfig }
}
export function getAgentEditingPath(marker: object | undefined): string | undefined {
return getAgentEdit(marker)?.path
}
export function setAgentEditingPath(
marker: object | undefined,
path: string | undefined,
details: Omit<AgentEdit, 'path'> = {}
) {
if (!marker) return
const rest = entries.filter((e) => e.marker !== marker)
entries = path ? [...rest.slice(-(MAX_ENTRIES - 1)), { marker, path, ...details }] : rest
}
// Carry edit state across a wholesale clone of the flow: the clone replaces every `tools` array
// (breaking marker identity) while preserving content verbatim, so re-keying entries to the
// cloned arrays by module id is safe there — unlike undo or session restores, which change
// content and must keep invalidating.
export function reanchorAgentEditsAcross(
getModules: () => FlowModule[] | undefined,
refresh: () => void
) {
const anchors: { key: string; edit: AgentEdit; marker: object }[] = []
forEachAgentFork(getModules(), (key, tools) => {
const edit = getAgentEdit(tools)
if (edit) anchors.push({ key, edit, marker: tools })
})
refresh()
if (anchors.length === 0) return
const byKey = new Map<string, object>()
forEachAgentFork(getModules(), (key, tools) => byKey.set(key, tools))
for (const a of anchors) {
setAgentEditingPath(a.marker, undefined)
const tools = byKey.get(a.key)
if (tools) {
const { path, ...details } = a.edit
setAgentEditingPath(tools, path, details)
}
}
}
// Visit every standalone (unlinked) agent's tools array, including nested agent tools. Keys are
// ancestry-qualified (`hostId/toolId`): resource-imported tool ids are not flow-global, so a
// nested agent may share a bare id with a flow module and must not alias it.
function forEachAgentFork(
modules: FlowModule[] | undefined,
visit: (key: string, tools: object) => void,
prefix: string = ''
) {
for (const mod of modules ?? []) {
const v = mod.value as FlowModule['value'] | undefined
if (!v) continue
if (v.type === 'forloopflow' || v.type === 'whileloopflow') {
forEachAgentFork(v.modules, visit, prefix)
} else if (v.type === 'branchone') {
forEachAgentFork(v.default, visit, prefix)
for (const b of v.branches ?? []) forEachAgentFork(b.modules, visit, prefix)
} else if (v.type === 'branchall') {
for (const b of v.branches ?? []) forEachAgentFork(b.modules, visit, prefix)
} else if (v.type === 'aiagent') {
const key = `${prefix}${mod.id}`
if (!v.agent && v.tools) {
visit(key, v.tools)
}
forEachAgentFork(v.tools as FlowModule[] | undefined, visit, `${key}/`)
}
}
}
@@ -1,93 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { FlowModule } from '$lib/gen'
import {
getAgentEdit,
getAgentEditingPath,
setAgentEditingPath,
reanchorAgentEditsAcross
} from './agentEditStore.svelte'
function tool(id: string): object {
return { id, value: { type: 'rawscript', tool_type: 'flowmodule', input_transforms: {} } }
}
function agentFork(id: string, tools: object[]): FlowModule {
return { id, value: { type: 'aiagent', tools, input_transforms: {} } } as unknown as FlowModule
}
describe('reanchorAgentEditsAcross', () => {
it('re-keys edit entries to the cloned tools arrays by module id', () => {
const toolsA: object[] = [tool('t1')]
const toolsB: object[] = [tool('t2')]
let modules = [agentFork('a', toolsA), agentFork('b', toolsB)]
setAgentEditingPath(toolsA, 'f/agents/one')
setAgentEditingPath(toolsB, 'f/agents/two')
reanchorAgentEditsAcross(
() => modules,
() => {
// stand-in for refreshStateStore's $state.snapshot deep clone
modules = JSON.parse(JSON.stringify(modules))
}
)
expect(getAgentEditingPath(toolsA)).toBeUndefined()
expect(getAgentEditingPath((modules[0].value as any).tools)).toBe('f/agents/one')
expect(getAgentEditingPath((modules[1].value as any).tools)).toBe('f/agents/two')
})
it('keeps host and nested-agent entries distinct when a nested tool reuses the host id', () => {
// Resource tool ids are not flow-global: a nested agent tool may share the host step's id.
const nestedTools: object[] = [tool('t2')]
const nested = agentFork('a', nestedTools)
const hostTools: object[] = [tool('t1'), nested]
let modules = [agentFork('a', hostTools)]
setAgentEditingPath(hostTools, 'f/agents/parent')
setAgentEditingPath(nestedTools, 'f/agents/nested')
reanchorAgentEditsAcross(
() => modules,
() => {
modules = JSON.parse(JSON.stringify(modules))
}
)
const newHost = (modules[0].value as any).tools
const newNested = newHost[1].value.tools
expect(getAgentEditingPath(newHost)).toBe('f/agents/parent')
expect(getAgentEditingPath(newNested)).toBe('f/agents/nested')
})
it('carries the deployed baseline with the path', () => {
const tools: object[] = [tool('t1')]
let modules = [agentFork('a', tools)]
setAgentEditingPath(tools, 'f/agents/one', { deployedConfig: '{"d":1}' })
reanchorAgentEditsAcross(
() => modules,
() => {
modules = JSON.parse(JSON.stringify(modules))
}
)
expect(getAgentEdit((modules[0].value as any).tools)).toEqual({
path: 'f/agents/one',
deployedConfig: '{"d":1}'
})
})
it('drops the entry when the module is gone after the refresh', () => {
const tools: object[] = [tool('t1')]
let modules = [agentFork('a', tools)]
setAgentEditingPath(tools, 'f/agents/one')
reanchorAgentEditsAcross(
() => modules,
() => {
modules = []
}
)
expect(getAgentEditingPath(tools)).toBeUndefined()
})
})
@@ -0,0 +1,63 @@
/**
* Which saved agent the agent editor is showing, and which of its tools.
*
* The editor edits the `ai_agent` resource through its own draft, so this holds a resource path
* rather than a flow node: the same agent opens the same editor from a flow step and from the
* resources page. Module-level rather than a context value because what opens it a step's card,
* a list row unmounts the moment the selection moves.
*/
export interface AgentEditorTarget {
path: string
/** The workspace the opener operates on; the nav workspace when absent. */
workspace?: string
toolId?: string
/** A level of the editor that is not the form. Mutually exclusive with `toolId`. */
view?: 'evals'
/** Where to re-resolve a graph's tool nodes after a deploy, when opened from a flow step. Only a
* real flow sets it: the agent editor offers no way to open a second editor from inside itself. */
host?: { flowPath: string; moduleId: string }
}
let target = $state<AgentEditorTarget | undefined>(undefined)
export function openAgentEditor(open: Omit<AgentEditorTarget, 'toolId' | 'view'>) {
target = { ...open }
}
export function closeAgentEditor() {
target = undefined
}
/** Navigate between the agent and one of its tools. Passing undefined returns to the agent. */
export function showAgentEditorTool(toolId: string | undefined) {
if (!target) return
target = { ...target, toolId, view: undefined }
}
/** Navigate between the agent and one of its own levels. Passing undefined returns to the agent. */
export function showAgentEditorView(view: AgentEditorTarget['view']) {
if (!target) return
target = { ...target, view, toolId: undefined }
}
export function agentEditorTarget(): AgentEditorTarget | undefined {
return target
}
/** How many times each agent has been written, so a surface that reads the resource can refetch it.
* The editor and the step card that opens it are separate components over one resource, neither
* owning the other, and a deploy that leaves the path alone changes nothing they otherwise key on. */
let agentWrites = $state<Record<string, number>>({})
function writeKey(workspace: string | undefined, path: string | undefined): string {
return `${workspace ?? ''}:${path ?? ''}`
}
export function markAgentWritten(workspace: string | undefined, path: string) {
const key = writeKey(workspace, path)
agentWrites[key] = (agentWrites[key] ?? 0) + 1
}
export function agentWriteCount(workspace: string | undefined, path: string | undefined): number {
return agentWrites[writeKey(workspace, path)] ?? 0
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { AI_AGENT_SCHEMA } from './flowInfers'
import {
AGENT_FIELD_BY_KEY,
AGENT_FIELDS,
agentFieldIsSet,
agentStreamingEnabled,
initialVisibleAgentFields
} from './agentFormFields'
const schemaProperties = AI_AGENT_SCHEMA.properties ?? {}
function set(key: string, transform: unknown): boolean {
return agentFieldIsSet(AGENT_FIELD_BY_KEY[key], transform)
}
describe('agentFieldIsSet', () => {
it('reads the seeded placeholder as unset', () => {
expect(set('temperature', { type: 'static', value: undefined })).toBe(false)
expect(set('temperature', undefined)).toBe(false)
// The same placeholder after a round trip through the API.
expect(set('temperature', { type: 'static', value: null })).toBe(false)
})
it('treats a value the runtime cannot tell from absence as unset', () => {
expect(set('output_type', { type: 'static', value: 'text' })).toBe(false)
expect(set('max_iterations', { type: 'static', value: 10 })).toBe(false)
expect(set('memory', { type: 'static', value: { kind: 'off' } })).toBe(false)
expect(set('user_attachments', { type: 'static', value: [] })).toBe(false)
})
it('reads streaming as unset while it is on, which is what an absent key does', () => {
expect(schemaProperties.streaming?.default).toBe(true)
expect(set('streaming', { type: 'static', value: true })).toBe(false)
expect(set('streaming', { type: 'static', value: false })).toBe(true)
})
it('reads anything the user authored as set', () => {
expect(set('temperature', { type: 'static', value: 0 })).toBe(true)
expect(set('output_type', { type: 'static', value: 'image' })).toBe(true)
expect(set('memory', { type: 'static', value: { kind: 'auto', context_length: 5 } })).toBe(true)
expect(set('max_iterations', { type: 'javascript', expr: 'flow_input.loops' })).toBe(true)
expect(set('max_iterations', { type: 'javascript', expr: '' })).toBe(false)
})
})
describe('initialVisibleAgentFields', () => {
it('shows only the core fields for a step that materialized every default', () => {
// What the pre-redesign form wrote on first render of a blank agent step.
const legacy = {
provider: { type: 'static', value: { kind: 'openai', resource: '$res:u/admin/openai' } },
output_type: { type: 'static', value: 'text' },
user_message: { type: 'static', value: undefined },
system_prompt: { type: 'static', value: undefined },
streaming: { type: 'static', value: true },
memory: { type: 'static', value: { kind: 'off' } },
output_schema: { type: 'static', value: null },
user_attachments: { type: 'static', value: [] },
max_completion_tokens: { type: 'static', value: null },
temperature: { type: 'static', value: null },
max_iterations: { type: 'static', value: 10 }
}
expect([...initialVisibleAgentFields(legacy, schemaProperties)].sort()).toEqual([
'provider',
'system_prompt',
'tools',
'user_message'
])
})
it('collapses a linked step to its flow-local inputs', () => {
const linkedSchema = {
user_message: schemaProperties.user_message,
user_attachments: schemaProperties.user_attachments
}
expect([...initialVisibleAgentFields({}, linkedSchema)]).toEqual(['user_message'])
})
it('covers every schema key, so no field can only be reached through the raw doc', () => {
const registered = new Set(AGENT_FIELDS.map((f) => f.key))
expect(Object.keys(schemaProperties).filter((k) => !registered.has(k))).toEqual([])
})
})
// Three chat surfaces decide whether to consume a stream from this, and the worker decides whether
// to send one from `streaming.unwrap_or(true)`. They agree only while absent means on here.
describe('agentStreamingEnabled', () => {
const step = (input_transforms: Record<string, any>, rest: Record<string, any> = {}) => ({
type: 'aiagent',
input_transforms,
...rest
})
it('reads an unwritten field as streaming', () => {
expect(agentStreamingEnabled(step({}))).toBe(true)
// What the API returns for the `{"type":"static"}` placeholder the schema backfill seeds.
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: null } }))).toBe(true)
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: true } }))).toBe(true)
})
it('only an explicit false holds the answer back', () => {
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: false } }))).toBe(false)
})
it('reads off what the step cannot answer for', () => {
// An image answer never streams, whatever `streaming` says.
expect(
agentStreamingEnabled(
step({
streaming: { type: 'static', value: true },
output_type: { type: 'static', value: 'image' }
})
)
).toBe(false)
// A linked step carries no brain: the agent's own `streaming: false` is invisible here.
expect(agentStreamingEnabled(step({}, { agent: 'u/admin/a' }))).toBe(false)
// An expression has no value until the run it would decide is already under way, on either
// of the two fields the answer depends on.
expect(
agentStreamingEnabled(step({ streaming: { type: 'javascript', expr: 'flow_input.s' } }))
).toBe(false)
expect(
agentStreamingEnabled(step({ output_type: { type: 'javascript', expr: 'flow_input.o' } }))
).toBe(false)
})
})
@@ -0,0 +1,232 @@
import { deepEqual } from 'fast-equals'
import type { InputTransform } from '$lib/gen'
/**
* How the AI agent form presents `AI_AGENT_SCHEMA`: which group a field belongs to, what it is
* called, and what leaving it unset actually does at runtime.
*
* Kept apart from the schema because none of it is JSON Schema: the schema stays the contract with
* the backend, this is the contract with the reader. It imports nothing from `flowInfers.ts`, so
* the two cannot cycle.
*/
export type AgentFieldGroup = 'model' | 'messages' | 'tools' | 'output'
/** Groups in the order a run assembles the request: pick a model, build the messages, let it call
* tools, shape what comes back. */
export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string }[] = [
{ id: 'model', label: 'Model' },
{ id: 'messages', label: 'Messages' },
{ id: 'tools', label: 'Tools' },
{ id: 'output', label: 'Output' }
]
/** The tool roster, which reads `flowModule.value.tools` rather than an `input_transforms` key.
* It lives in the registry so the groups keep a single ordering. */
export const AGENT_TOOLS_ROW = 'tools'
export interface AgentFieldSpec {
key: string
group: AgentFieldGroup
/** Names the field wherever it appears: its row, the add menu, a linked agent's summary. */
label: string
tooltip?: string
/** Always rendered, and not removable. */
core?: boolean
/** Rendered by the form itself rather than as an `input_transforms` row. */
virtual?: boolean
/** The value a run cannot tell apart from an absent key, which is what makes holding it mean
* "unset". Read off the backend rather than `schema.default`, which the two have disagreed on
* before. Also what the add menu seeds the field with, so a new row opens showing what it
* overrides. */
implicit?: unknown
/** The same value written for a reader, shown under the field's name in the add menu. */
defaultHint?: string
/** Ignored for image output, so the field hides while `output_type` is `'image'`. */
textOnly?: boolean
/** Filled in per run rather than configured on the step, so a form that is collecting a run's
* inputs shows it whether or not the step wrote anything for it. The step's own form still
* treats it as optional: there it is one of the fields the add menu offers. */
runInput?: boolean
}
export const AGENT_FIELDS: AgentFieldSpec[] = [
{
key: 'provider',
group: 'model',
label: 'Provider',
core: true
},
// Not text-only, unlike the fields around it: image output runs through an ordinary chat model
// (OpenAI's `image_generation` tool, OpenRouter's `modalities`) that samples at this
// temperature, so hiding it would hide a setting the run still uses.
{
key: 'temperature',
group: 'model',
label: 'Temperature',
tooltip: 'How random the generation is, from 0 for deterministic up to 2.',
defaultHint: 'Default: the provider decides'
},
{
key: 'max_completion_tokens',
group: 'model',
label: 'Max output tokens',
tooltip: 'The most tokens the model may produce in its answer.',
defaultHint: 'Default: the provider decides'
},
{
key: 'system_prompt',
group: 'messages',
label: 'System message',
tooltip: 'Sets how the agent should behave. Sent ahead of everything else.',
core: true
},
{
key: 'memory',
group: 'messages',
label: 'Memory',
tooltip:
'History sent between the system message and the user message. Windmill can keep it for you, or you can supply the messages yourself.',
implicit: { kind: 'off' },
defaultHint: 'Default: off',
textOnly: true
},
{
key: 'user_message',
group: 'messages',
label: 'User message',
tooltip:
"The user turn, sent after the system message and any history. Turn on chat input on the flow's input interface to feed it from the chat.",
core: true
},
{
key: 'user_attachments',
group: 'messages',
label: 'Attachments',
tooltip: 'Images or PDFs sent along with the user message. Needs S3 storage on the workspace.',
implicit: [],
defaultHint: 'Default: none',
runInput: true
},
{
key: AGENT_TOOLS_ROW,
group: 'tools',
label: 'Tools',
core: true,
virtual: true
},
{
key: 'max_iterations',
group: 'tools',
label: 'Max iterations',
tooltip:
'How many times the agent may go round the loop of calling the model and running the tools it asks for. One iteration can run several tools. If it is still calling tools at the last one, the step fails and returns the messages so far. Between 1 and 1000.',
implicit: 10,
defaultHint: 'Default: 10'
},
{
key: 'output_type',
group: 'output',
label: 'Output type',
tooltip:
'Image output needs S3 storage on the workspace, ignores tools, and works with OpenAI, Google AI and the OpenRouter gemini-image-preview model.',
implicit: 'text',
defaultHint: 'Default: text'
},
{
key: 'output_schema',
group: 'output',
label: 'Output schema',
tooltip: 'A JSON schema the answer has to follow.',
defaultHint: 'Default: none',
textOnly: true
},
{
key: 'streaming',
group: 'output',
label: 'Stream the response',
tooltip: 'Send the answer back as it is generated, rather than once it is complete.',
implicit: true,
defaultHint: 'Default: on',
textOnly: true
}
]
export const AGENT_FIELD_BY_KEY: Record<string, AgentFieldSpec> = Object.fromEntries(
AGENT_FIELDS.map((f) => [f.key, f])
)
/**
* Whether a transform holds something a run would do differently from an absent key. Core fields
* are always set: they are what an agent is.
*/
export function agentFieldIsSet(
spec: AgentFieldSpec,
transform: InputTransform | any | undefined
): boolean {
if (spec.core) return true
if (!transform || typeof transform !== 'object') return false
if (transform.type === 'javascript') return Boolean(transform.expr)
// An AI-filled field is the agent tool case: the value arrives at runtime, so it is set.
if (transform.type === 'ai') return true
const value = transform.value
// `null` as well as `undefined`: a placeholder transform serializes as `{"type":"static"}` and
// comes back from the API with an explicit `"value": null`, so the two shapes are the same field.
if (value === undefined || value === null) return false
if (spec.implicit !== undefined && deepEqual(value, spec.implicit)) return false
return true
}
/**
* Whether a run of this step would stream its answer, mirroring the worker's
* `has_stream = user_wants_streaming && is_text_output`. Absence means on
* (`args.streaming.unwrap_or(true)`), so an unwritten field streams.
*
* A caller that reads this wrong does not merely mislabel the run: a chat surface that opens a
* stream for an answer the worker sends in one piece re-runs the flow when its connection times
* out. So the rule is that anything this cannot settle from the step alone reads as off, the cost
* of being wrong that way being a live answer arriving at the end instead of as it is written.
* Unsettled means either of the two fields holding an expression, whose value exists only once the
* run it decides is already under way, or a linked agent, whose brain lives in the resource where
* this has no sight of it at all.
*/
export function agentStreamingEnabled(value: Record<string, any> | undefined): boolean {
if (value?.agent) return false
const transforms = value?.input_transforms as Record<string, InputTransform | any> | undefined
const settled = (t: InputTransform | any | undefined) => t == undefined || t.type === 'static'
const outputType = transforms?.output_type
const streaming = transforms?.streaming
if (!settled(outputType) || !settled(streaming)) return false
// An image answer never streams, whatever `streaming` says.
if (outputType?.value === 'image') return false
return streaming?.value !== false
}
/**
* Whether the current schema carries this field at all. A linked step's schema is reduced to the
* flow-local inputs, which is what collapses its form to the Messages group on its own.
*/
export function agentFieldAppliesTo(
spec: AgentFieldSpec,
schemaProperties: Record<string, any> | undefined
): boolean {
// A virtual row has no schema key to look for, so it keys off the brain being editable here.
if (spec.virtual) return Boolean(schemaProperties && 'provider' in schemaProperties)
return Boolean(schemaProperties && spec.key in schemaProperties)
}
/**
* The fields to render when a step is first opened. Visibility is sticky from here on: the form
* only ever adds to this set, so emptying a textbox never makes its row vanish under the cursor.
*/
export function initialVisibleAgentFields(
args: Record<string, InputTransform | any> | undefined,
schemaProperties: Record<string, any> | undefined
): Set<string> {
const visible = new Set<string>()
for (const spec of AGENT_FIELDS) {
if (!agentFieldAppliesTo(spec, schemaProperties)) continue
if (agentFieldIsSet(spec, args?.[spec.key])) visible.add(spec.key)
}
return visible
}
@@ -1,15 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
agentConfigAsEdited,
agentConfigToInputTransforms,
agentEditorRefusal,
flowLocalInputs,
inputTransformsToAgentConfig,
nonStaticBrainKeys,
summarizeAgentBrain,
toolInputOverrides
toolInputOverrides,
transformValuedBrainKeys
} from './agentResourceUtils'
describe('transformValuedBrainKeys', () => {
// The tag is the whole test: `{type:'ai'}` carries no payload key to look for.
it('flags every transform variant, whatever it carries', () => {
expect(
transformValuedBrainKeys({
system_prompt: { type: 'ai' },
temperature: { type: 'javascript', expr: 'flow_input.t' },
max_iterations: { type: 'static', value: 3 }
})
).toEqual(['system_prompt', 'temperature', 'max_iterations'])
})
it('leaves the brain values that are legitimately objects', () => {
expect(
transformValuedBrainKeys({
output_schema: { type: 'object', properties: {} },
memory: { kind: 'auto', context_length: 20 },
provider: { kind: 'openai', model: 'gpt-4o' },
system_prompt: 'hi'
})
).toEqual([])
})
})
describe('agentEditorRefusal', () => {
it('opens an agent', () => {
expect(agentEditorRefusal('u/admin/agent', 'ai_agent')).toBeUndefined()
})
// A path with no deployed row answers with its own draft, and the generic resource editor writes
// no type: assuming `ai_agent` there would create that path as an agent on deploy.
it('refuses an unknown type rather than assuming one', () => {
expect(agentEditorRefusal('u/admin/db', undefined)).toContain('no deployed agent')
})
})
describe('summarizeAgentBrain', () => {
it('returns only set fields, in brain-key order, formatted', () => {
const rows = summarizeAgentBrain({
@@ -20,9 +57,9 @@ describe('summarizeAgentBrain', () => {
max_iterations: 10
})
expect(rows).toEqual([
{ label: 'Model', value: 'openai · gpt-4o' },
{ label: 'System prompt', value: 'You are helpful' },
{ label: 'Streaming', value: 'on' },
{ label: 'Provider', value: 'openai · gpt-4o' },
{ label: 'System message', value: 'You are helpful' },
{ label: 'Stream the response', value: 'on' },
{ label: 'Temperature', value: '0.7' },
{ label: 'Max iterations', value: '10' }
])
@@ -70,24 +107,6 @@ describe('inputTransformsToAgentConfig', () => {
})
})
describe('agentConfigAsEdited', () => {
it('keeps an expression as itself, where the saved config would drop it', () => {
const transforms = {
system_prompt: { type: 'static', value: 'hi' },
temperature: { type: 'javascript', expr: 'flow_input.t' },
max_iterations: { type: 'static', value: undefined },
user_message: { type: 'static', value: 'hello' }
} as any
expect(agentConfigAsEdited(transforms, [])).toEqual({
tools: [],
system_prompt: 'hi',
temperature: { type: 'javascript', expr: 'flow_input.t' }
})
// The saved shape is blind to the expression: that is the difference this exists for.
expect(inputTransformsToAgentConfig(transforms, [])).toEqual({ tools: [], system_prompt: 'hi' })
})
})
describe('agentConfigToInputTransforms', () => {
it('wraps brain values as static transforms and round-trips through the config', () => {
const config = { provider: { kind: 'openai' }, system_prompt: 'hi', tools: [{ id: 't1' }] }
@@ -1,5 +1,6 @@
import { deepEqual } from 'fast-equals'
import type { InputTransform } from '$lib/gen'
import { AGENT_FIELDS } from './agentFormFields'
// The brain fields stored flat in an `ai_agent` resource value. The flow-local inputs
// (user_message/user_attachments) are intentionally excluded — they are supplied per-flow.
@@ -19,6 +20,21 @@ export const AGENT_FLOW_LOCAL_KEYS = ['user_message', 'user_attachments'] as con
export type AgentTool = Record<string, any>
/**
* Why this resource cannot be edited as an agent, if it cannot. A path with no deployed row answers
* with its own draft, whose type the generic resource editor never writes, so an unknown type is
* refused rather than assumed: deploying it would create that path as an agent.
*/
export function agentEditorRefusal(
path: string,
resourceType: string | undefined
): string | undefined {
if (resourceType === 'ai_agent') return undefined
return resourceType
? `${path} is a ${resourceType} resource, not an agent.`
: `${path} has no deployed agent to edit.`
}
/** Brain keys whose step transform is non-static and would be dropped by a save-as-agent snapshot. */
export function nonStaticBrainKeys(
inputTransforms: Record<string, InputTransform> | undefined
@@ -87,35 +103,17 @@ export function inputTransformsToAgentConfig(
const config: AIAgentConfig = { tools: tools ?? [] }
for (const key of AGENT_BRAIN_KEYS) {
const t = inputTransforms?.[key] as any
if (t && t.type === 'static' && t.value !== undefined) {
// `null` as well as `undefined`: a placeholder transform is `{"type":"static"}`, and it comes
// back from the API — and from a schema backfill — with an explicit null. Writing it through
// would put `memory: null` in the saved agent and show as a change against a config that
// simply omits the key.
if (t && t.type === 'static' && t.value !== undefined && t.value !== null) {
;(config as any)[key] = t.value
}
}
return config
}
/**
* The configuration as the step holds it, in the shape of the saved config: a static transform as
* its value, any other transform as itself. The saved config keeps static values only, so a
* comparison in that shape is blind to an expression which the step runs, and linking strips.
*/
export function agentConfigAsEdited(
inputTransforms: Record<string, InputTransform> | undefined,
tools: AgentTool[] | undefined
): Record<string, unknown> {
const config: Record<string, unknown> = { tools: tools ?? [] }
for (const key of AGENT_BRAIN_KEYS) {
const t = inputTransforms?.[key] as any
if (!t) continue
if (t.type === 'static') {
if (t.value !== undefined) config[key] = t.value
} else {
config[key] = t
}
}
return config
}
/**
* Reduce the AI agent schema to only the flow-local inputs. Used when a step is linked to a saved
* agent: the brain fields come from the resource, so only user_message/user_attachments stay editable.
@@ -138,16 +136,26 @@ export function flowLocalAgentSchema(schema: any): any {
}
}
const AGENT_BRAIN_LABELS: Record<string, string> = {
provider: 'Model',
output_type: 'Output type',
system_prompt: 'System prompt',
streaming: 'Streaming',
memory: 'Memory',
output_schema: 'Output schema',
max_completion_tokens: 'Max tokens',
temperature: 'Temperature',
max_iterations: 'Max iterations'
/** Read off the form's own registry, so a linked agent's summary cannot name a field differently
* from the form that edits it. */
export const AGENT_BRAIN_LABELS: Record<string, string> = Object.fromEntries(
AGENT_FIELDS.map((f) => [f.key, f.label])
)
/**
* Brain keys set to an input transform rather than to a value, which a saved agent's plain JSON
* cannot hold. Matched on the tag so the whole `InputTransform` union is covered: a payload-key
* test would miss `{"type":"ai"}`, which carries none.
*/
export function transformValuedBrainKeys(args: Record<string, any> | undefined): string[] {
return AGENT_BRAIN_KEYS.filter((key) => {
const v = args?.[key]
return (
v !== null &&
typeof v === 'object' &&
(v.type === 'javascript' || v.type === 'ai' || v.type === 'static')
)
})
}
/** Flatten a saved agent's brain config into human-readable label/value rows for a read-only
@@ -21,6 +21,11 @@ export function getToolNameError(
if (kind === 'mcp') {
return name.length > 0 ? undefined : 'Tool name must not be empty'
}
// Ahead of the pattern, which an empty name also fails: "must only contain letters" reads as a
// complaint about characters that are not there.
if (name.length === 0) {
return 'Tool name must not be empty'
}
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
return 'Tool name must only contain letters, numbers and underscores'
}
@@ -60,6 +65,9 @@ export type WebsearchTool = AgentTool & {
* Type guard to check if a tool is a FlowModule tool
*/
export function isFlowModuleTool(tool: AgentTool): tool is FlowModuleTool {
// `value` must be there, not merely lack a `tool_type`: the tool list is JSON-authored, and an
// entry without one is not a flowmodule tool for the callers that go on to read its script.
if (tool?.value == undefined) return false
return tool.value.tool_type === undefined || tool.value.tool_type === 'flowmodule'
}
@@ -67,14 +75,28 @@ export function isFlowModuleTool(tool: AgentTool): tool is FlowModuleTool {
* Type guard to check if a tool is an MCP tool
*/
export function isMcpTool(tool: AgentTool): tool is McpTool {
return tool.value.tool_type === 'mcp'
return tool?.value?.tool_type === 'mcp'
}
/**
* Type guard to check if a tool is a Websearch tool
*/
export function isWebsearchTool(tool: AgentTool): tool is WebsearchTool {
return tool.value.tool_type === 'websearch'
return tool?.value?.tool_type === 'websearch'
}
/** The only input a nested agent used as a tool has the calling agent fill: the rest is its own
* configuration, not something to generate. Mirrors the server, which offers such a tool a schema
* of `{user_message}` and nothing else (`AI_AGENT_TOOL_SCHEMA` in `ai_executor.rs`); anything else
* left AI-filled here is dropped from that schema and never reaches the model. */
export const AI_AGENT_TOOL_AI_KEYS = ['user_message']
/** What a tool is called wherever it is named: its own name, else what it points at. Never its id,
* which is internal. Undefined when it has nothing to be called yet an MCP tool with no server
* picked is unnamed rather than misnamed, so each surface words that for itself. */
export function toolDisplayName(tool: AgentTool): string | undefined {
const value = tool?.value as Record<string, any>
return tool?.summary || value?.path || value?.resource_path || undefined
}
/**
@@ -0,0 +1,457 @@
<script lang="ts">
import { getContext, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import { Alert } from '$lib/components/common'
import { deepEqual } from 'fast-equals'
import type { Flow, FlowModule, InputTransform, Job, OpenFlow } 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 { AGENT_TOOLS_ROW } from '../agentFormFields'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import { useAgentDraft } from '../agentDraft.svelte'
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>
}
let {
path,
workspace,
enableAi = false,
toolId = undefined,
onSelectTool = undefined,
onSaved = 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 })
/** 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(!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)
// 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]
// 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)
)
})
})
/** Every argument the resource carries, as a static transform. `tools` is the roster rather than
* a field, so it rides on the module's own key instead. Not only the keys the form renders: a
* run reads them all, and an agent holding its own `user_message` answers with it when nothing
* overrides it, so a test here has to run the configuration a linked step would. */
function agentArgsToTransforms(args: AIAgentConfig): Record<string, InputTransform> {
const it: Record<string, InputTransform> = {}
for (const [key, value] of Object.entries(args ?? {})) {
if (key === 'tools' || value === undefined) continue
it[key] = { type: 'static', value } as InputTransform
}
return it
}
/** 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)
// 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)
}
export function deploy(): Promise<boolean> {
return draft.deploy().then(async (ok) => {
// The path this editor opened, not the draft's live one: `deploy` refuses a renaming draft,
// so the write always lands here, while the shared draft can be repointed by another tab
// mid-request and would send the reconciliation after a resource nobody wrote.
if (ok) await onSaved?.(path)
return ok
})
}
export function draftHandle() {
return draft
}
</script>
{#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">
<!-- 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={66} minSize={30}>
<div class="h-full min-h-0 overflow-auto">
<PropPickerWrapper
pickableProperties={stepPropPicker?.pickableProperties}
noPadding
sidePane
>
<AiAgentStepInputs
class="px-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>
</Pane>
<!-- Laid out as the script editor's preview column is: what a run takes above what it
produced, both alongside what is being edited. -->
<Pane size={34} minSize={20}>
<Splitpanes horizontal class="h-full">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
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>
</Pane>
</Splitpanes>
</div>
<!-- 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
noToolNavigation
forceTestTab={readOnly ? undefined : { [tool.id]: true }}
siblingToolNames={tools.filter((t) => t?.id !== tool?.id).map((t) => t?.summary ?? '')}
/>
</div>
{/if}
</DrawerContent>
</Drawer>
{/if}
@@ -0,0 +1,368 @@
<script lang="ts">
import { FlaskConical, History, Save } from 'lucide-svelte'
import { onDestroy, untrack } from 'svelte'
import { resource } from 'runed'
import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
import { clearPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession'
import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte'
import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils'
import { ResourceService, type AgentDraft } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import {
agentEditorTarget,
agentWriteCount,
type AgentEditorTarget,
closeAgentEditor,
markAgentWritten,
showAgentEditorTool,
showAgentEditorView
} from '../agentEditorStore.svelte'
import { agentConfigToInputTransforms } from '../agentResourceUtils'
import { publishLinkedAgentTools } from '../flowState'
import { linkedModulesForAgent, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
import AgentEditorHost from './AgentEditorHost.svelte'
interface Props {
enableAi?: boolean
/** Which targets this mount is responsible for. The target is module-global while several
* flow editors can be alive at once (a session retains every tab it has visited), so without
* this each of them would build a whole editor — fetching, inferring tool schemas, and
* running its own two-way sync against the one draft row. Required rather than defaulted: a
* mount that guesses wrong renders nothing, and silence is a poor way to find that out. */
owns: (target: AgentEditorTarget) => boolean
}
let { enableAi = false, owns }: Props = $props()
// Every target names the surface that opened it, and only a flow step or a resource row can:
// an agent used as a tool of the agent being edited stays part of it, with no way in this editor
// to link it to a saved agent of its own. So `owns` decides alone — nothing here has to adopt a
// target opened from inside another editor.
let target = $derived.by(() => {
const t = agentEditorTarget()
return t && owns(t) ? t : undefined
})
// The last agent this dialog showed. The version pane keeps working after `onRestore` returns —
// it reloads its list keyed on the path it was given — while the restore handler has already
// closed the dialog and dropped the target. Reading the live one there throws, and the throw
// lands in the global unhandled-rejection handler as a bare toast with no stack.
let lastShownAgent = $state<string | undefined>(undefined)
$effect(() => {
const p = target?.path
if (p) untrack(() => (lastShownAgent = p))
})
let ws = $derived(target?.workspace ?? $workspaceStore)
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let versionDrawer: Drawer | undefined = $state(undefined)
let saving = $state(false)
// Counted per agent, so a deploy that leaves the editor on the same agent still refetches the
// version it just minted. Shared, so the step card behind the dialog refetches on the same signal.
let writes = $derived(agentWriteCount(ws, target?.path))
let versionResource = resource(
() => ({ ws, path: target?.path, writes }),
async ({ ws, path, writes }) => {
if (!ws || !path) return { ws, path, writes }
const history = await ResourceService.getResourceHistory({ workspace: ws, path })
return { ws, path, writes, version: history.versions?.[0]?.version }
}
)
// `writes` as well as the identity: runed neither aborts nor tags a superseded request, so a
// response from before a deploy can land after the one for it and relabel the badge with the
// version that deploy replaced.
let version = $derived.by(() => {
const loaded = versionResource.current
return loaded !== undefined &&
loaded.ws === ws &&
loaded.path === target?.path &&
loaded.writes === writes
? loaded.version
: undefined
})
let draft = $derived(host?.draftHandle())
// The load refused this path, so there is no agent to deploy, evaluate or show a history of:
// the dialog carries only the refusal the host renders.
let refused = $derived(draft?.refusal != null)
let inEvals = $derived(target?.view === 'evals' && !refused)
let readOnly = $derived(draft ? !draft.canWrite : false)
let draftOnly = $derived(draft?.noDeployed ?? false)
// Where the evals pane is within itself, so its levels extend this dialog's trail rather than
// opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on
// one, and never that it is back at its root.
let evalsLocation = $state<EvalsLocation | undefined>(undefined)
let evalsKey = $derived(`${ws}:${target?.path}:${target?.view}`)
$effect(() => {
evalsKey
untrack(() => (evalsLocation = undefined))
})
const AGENT_DESCRIPTION = 'Changes here update the saved agent, and every flow that links to it.'
let root = $derived<ModalTrailSegment>({
label: target?.path ?? 'Agent',
onclick: inEvals ? () => showAgentEditorView(undefined) : undefined
})
let trail = $derived<ModalTrailSegment[]>(
inEvals
? [
root,
{ label: 'Evals', onclick: evalsLocation ? evalsLocation.back : undefined },
...(evalsLocation ? [{ label: evalsLocation.label }] : [])
]
: [root]
)
// The root's alone: below it the header's second line is the way back, and what a level is for
// belongs to that level rather than to the dialog's own name.
let description = $derived(inEvals || refused ? undefined : AGENT_DESCRIPTION)
/** The unsaved edits, in the shape the server builds from a deployed config
* (`ai_evals/run.rs` `config_to_draft`), so a draft run's hash can be recognised as equal to
* the version it is later deployed as. */
function editedConfig(): AgentDraft {
const args = draft?.state?.args
return {
input_transforms: agentConfigToInputTransforms(args ?? {}) as Record<string, unknown>,
tools: (args?.tools ?? []) as unknown[] as Record<string, unknown>[]
}
}
/** A resources-page row anchors itself in the hash on the way in, so closing has to clear it or
* the URL keeps claiming the agent is open and a refresh reopens it. Anchored to that page, so
* this is a no-op when the editor was opened from a flow. */
function close() {
closeAgentEditor()
void clearPageDrawerAnchor(RESOURCES_PATH)
}
// The target is module-global and outlives this component: navigating away takes the mount that
// owns it without passing through `close()`, and what it leaves behind reopens the dialog on the
// way back and tells the flow editor a dialog is over its graph. Only its own, so a mount going
// down never drops a target another one is showing.
onDestroy(() => {
const t = agentEditorTarget()
if (t && owns(t)) closeAgentEditor()
})
/** What the flow behind a write has to be told about it, captured before the request rather than
* read after: the dialog stays closable while one is in flight and can be pointed at another
* agent by the time it lands, and the reactive `ws`/`target` would then name that one. */
type WriteTarget = { ws: string | undefined; path: string; host: AgentEditorTarget['host'] }
function currentWriteTarget(): WriteTarget | undefined {
const t = target
return t ? { ws, path: t.path, host: t.host } : undefined
}
/** Reconciling is best-effort: it refreshes what a write changed, and the write itself has
* already landed. A rejection here must not surface as an unhandled one — the global handler
* turns those into a bare toast with no stack, which says nothing about where it came from. */
function reconcileQuietly(at: WriteTarget | undefined, path: string) {
try {
void Promise.resolve(reconcile(at, path)).catch((err) => {
console.error('agent editor: could not refresh what links to', path, err)
})
} catch (err) {
console.error('agent editor: could not refresh what links to', path, err)
}
}
function reconcile(at: WriteTarget | undefined, path: string) {
if (!at?.ws) return
markAgentWritten(at.ws, path)
if (!at.host) return
// The host graph resolves a linked agent's tool nodes from the resource, so it has to re-read
// what the write just changed. Every step of that flow linking this agent, not only the one
// the editor was opened from: they all show tools the write may have moved.
const scope = linkedToolsScope(at.ws, at.host.flowPath)
const moduleIds = new Set(linkedModulesForAgent(scope, path))
moduleIds.add(at.host.moduleId)
return Promise.all(
[...moduleIds].map((moduleId) => publishLinkedAgentTools(path, at.ws, scope, moduleId))
)
}
let deployingFor = $state<WriteTarget | undefined>(undefined)
async function onDeploy() {
saving = true
deployingFor = currentWriteTarget()
try {
await host?.deploy()
} finally {
saving = false
deployingFor = undefined
}
}
/** What a successful deploy has to reconcile. The path is the one it wrote, which `deploy` holds
* to the one the editor opened: this editor does not rename. */
async function onSaved(savedPath: string) {
await reconcile(deployingFor ?? currentWriteTarget(), savedPath)
}
</script>
{#if target}
{#key `${ws}:${target.path}`}
<!-- Bound rather than held open: the store is what decides whether this is mounted, so every
way out has to reach it. The close button and the backdrop only set `open`, and a
dialog left closed over a target still set could never be opened again.
`paginated` throughout: the body is one strip of pages (the agent form, then evals), so
the header keeps its height and only the pages move. -->
<Modal
bind:open={() => true, (open) => !open && close()}
kind="X"
fillHeight
enterConfirms={false}
paginated
title={target.path}
{trail}
{description}
class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]"
>
{#snippet titleBadge()}
<!-- Against the agent's own name wherever it appears, as the linked-agent card in the
step panel has it. -->
{#if version != undefined && !refused}
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
v{version}
</Badge>
{/if}
{/snippet}
{#snippet levelBadge()}
<!-- Marks evals, not the agent, so it sits against that level's own name. Dropped a
level deeper, where it would read as marking the run rather than the feature it
belongs to. -->
{#if inEvals && !evalsLocation}
<Badge color="blue" small class="shrink-0 !py-0 leading-4">Beta</Badge>
{/if}
{/snippet}
<!-- Evals carries none of the editor's actions: nothing there edits the agent, and the run
dialog states for itself whether a run is against the deployed version or the edits. -->
{#snippet settings()}
<div class="flex flex-row items-center gap-2 shrink-0">
{#if !inEvals && !refused}
{#if readOnly}
<Badge
color="gray"
class="shrink-0"
title="You do not have write access to this agent"
>
Read only
</Badge>
{/if}
<!-- Evals run against the deployed agent, and a draft-only one has none: the
backend's `require_agent` would reject every run. -->
{#if !draftOnly}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: FlaskConical }}
title="Run this agent against a dataset of cases"
on:click={() => showAgentEditorView('evals')}
>
Evals
</Button>
{/if}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: History }}
iconOnly
title="Version history"
on:click={() => versionDrawer?.openDrawer()}
/>
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Save }}
loading={saving}
disabled={readOnly}
title={readOnly ? 'You do not have write access to this agent' : undefined}
on:click={onDeploy}
>
Deploy
</Button>
{/if}
</div>
{/snippet}
<div class="h-full min-h-0 flex flex-col">
<!-- Full-bleed, as a banner is everywhere else: the dialog's own horizontal padding is
cancelled so it spans the body. -->
<div class="-mx-4 sm:-mx-6 shrink-0 {inEvals ? 'hidden' : ''}">
<LocalDraftBanner
show={draft?.sync.hasDraft ?? false}
reserveSpace={draft?.sync.hasBaseline ?? false}
getDeployed={() => draft?.deployed}
getCurrent={() => draft?.state}
onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')}
title="Deployed <> Unsaved agent changes"
/>
</div>
<!-- Two pages of one strip, so opening evals slides in from the right the way its own
levels do. No `onNavigate`: the arrow keys belong to whichever pane is on screen,
and evals answers them for its own levels. -->
<PagedContent
class="flex-1 min-h-0"
current={inEvals ? 'evals' : 'agent'}
pages={[
{ key: 'agent', content: agentPage },
{ key: 'evals', content: evalsPage }
]}
/>
</div>
</Modal>
{/key}
<!-- Mounted under the evals level too: it holds the draft the header's banner and Deploy act on,
and the config a draft run is offered on. -->
{#snippet agentPage()}
<AgentEditorHost
bind:this={host}
path={target.path}
workspace={ws}
{enableAi}
toolId={target.toolId}
onSelectTool={(id) => showAgentEditorTool(id)}
{onSaved}
/>
{/snippet}
{#snippet evalsPage()}
<EvalsPane
agentPath={target.path}
opWorkspace={ws}
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
bind:location={evalsLocation}
active={inEvals}
/>
{/snippet}
<Drawer bind:this={versionDrawer} size="1200px">
<DrawerContent title="Version history" on:close={() => versionDrawer?.closeDrawer()} noPadding>
<ResourceVersionHistory
path={target?.path ?? lastShownAgent ?? ''}
workspace={ws}
canRestore={!readOnly}
onRestore={() => {
versionDrawer?.closeDrawer()
// A restore writes the resource as a deploy does, so the flow behind has to be told
// the same way. Captured before closing, which drops the target this reads.
const at = currentWriteTarget()
// Close the editor too, as the generic resource editor does on a restore: it holds
// a baseline captured before the restore, and any local draft on top of it, so
// deploying from it afterwards would write the pre-restore value straight back over
// the version just restored.
close()
if (at) reconcileQuietly(at, at.path)
}}
/>
</DrawerContent>
</Drawer>
{/if}
@@ -2,21 +2,15 @@
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import Path from '$lib/components/Path.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { ResourceService, type AgentDraft, type InputTransform } from '$lib/gen'
import { ResourceService, type InputTransform } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Bot, ChevronDown, ChevronUp, FlaskConical, Save, Unlink, Pencil } from 'lucide-svelte'
import AgentEvalModal from '$lib/components/aiEvals/AgentEvalModal.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { Value } from '$lib/utils'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte'
import {
AGENT_BRAIN_KEYS,
AGENT_FLOW_LOCAL_KEYS,
agentConfigAsEdited,
agentConfigToInputTransforms,
flowLocalInputs,
inputTransformsToAgentConfig,
@@ -25,12 +19,12 @@
type AIAgentConfig,
type AgentTool
} from '../agentResourceUtils'
import { agentWriteCount, markAgentWritten, openAgentEditor } from '../agentEditorStore.svelte'
import {
setLinkedAgentTools,
clearLinkedAgentTools,
linkedToolsScope
} from '../linkedAgentToolsStore.svelte'
import { getAgentEdit, getAgentEditingPath, setAgentEditingPath } from '../agentEditStore.svelte'
import { logReusableAgentUsage } from '../agentTelemetry'
import { claimLinkedToolsFetch } from '../flowState'
import type { AgentTool as AgentToolStrict } from '../agentToolUtils'
@@ -44,7 +38,8 @@
toolInputs = $bindable(),
moduleId,
opWorkspace = undefined,
flowPath = ''
flowPath = '',
fromAgentEditor = false
}: {
agent: string | undefined
inputTransforms: Record<string, InputTransform>
@@ -56,28 +51,36 @@
opWorkspace?: string
// Scope for the linked-agent tools store (the flow path); must match what the graph reads.
flowPath?: string
// Inside the agent editor, where an agent used as a tool stays part of the agent being
// edited: it cannot be saved as a reusable agent of its own, and one already linked cannot be
// opened here. Linking a saved agent to another agent is out of scope for this editor — the
// backend supports it, but only a flow can author it, and a second editor over a second draft
// is the wrong way in.
fromAgentEditor?: boolean
} = $props()
let ws = $derived(opWorkspace ?? $workspaceStore)
// How many times the linked agent has been written, from anywhere: this card's own save, or a
// deploy from the agent editor mounted alongside it. Both reads below key on it, so neither
// keeps naming the config and version a write has just replaced.
let writes = $derived(agentWriteCount(ws, agent))
let saveDrawer: Drawer | undefined = $state()
let newPath = $state('')
let pathError = $state('')
let description = $state('')
let saving = $state(false)
// The edit session of a step forked from a saved agent: the path "Save changes" upserts back
// to, and the baselines the edits are judged against. Lives in an external store so it
// survives this component unmounting — another node selected, the step's tab switched — keyed
// by the forked `tools` identity so a stale entry can't resurface (see agentEditStore).
let editing = $derived(getAgentEdit(tools))
let editingPath = $derived(editing?.path)
type LinkedInfo = {
// What this result was fetched for. runed's resource neither aborts nor tags a superseded
// request, so a slow fetch for a previous link can land after a newer one: every consumer
// gates on these matching the current (ws, agent).
// request, so a slow fetch can land after a newer one: every consumer gates on these matching
// the current (ws, agent, writes). `writes` is what covers a refetch of the *same* link after
// a deploy — without it a pre-deploy response is indistinguishable from the current one, and
// accepting it republishes the tools the deploy just replaced.
ws?: string
path?: string
writes: number
config: AIAgentConfig
tools: AgentTool[]
providerPath?: string
@@ -88,10 +91,10 @@
// load them here for display, and probe the provider resource so we can warn when it isn't
// accessible in this workspace (the user then needs to unlink/fork or gain access).
let linkedResource = resource(
() => ({ ws, path: agent }),
async ({ ws, path }): Promise<LinkedInfo> => {
() => ({ ws, path: agent, writes }),
async ({ ws, path, writes }): Promise<LinkedInfo> => {
if (!ws || !path) {
return { ws, path, config: {}, tools: [], providerOk: true }
return { ws, path, writes, config: {}, tools: [], providerOk: true }
}
const res = await ResourceService.getResource({ workspace: ws, path })
const cfg = (res.value ?? {}) as AIAgentConfig & { provider?: { resource?: string } }
@@ -112,6 +115,7 @@
return {
ws,
path,
writes,
config: cfg,
tools,
providerPath,
@@ -125,7 +129,7 @@
let loadedInfo = $state<LinkedInfo | undefined>(undefined)
$effect(() => {
const current = linkedResource.current
if (current && current.ws === ws && current.path === agent) {
if (current && current.ws === ws && current.path === agent && current.writes === writes) {
loadedInfo = current
}
})
@@ -137,27 +141,31 @@
let providerPath = $derived(linkedInfo?.providerPath)
let providerOk = $derived(linkedInfo?.providerOk ?? true)
/** The agent the card is about: the one this step links to, or the one being edited. */
let cardPath = $derived(agent ?? editingPath)
let evalsOpen = $state(false)
// Bumped on every write to the resource, so a save that leaves the card on the same agent still
// refetches the version it just minted.
let writes = $state(0)
let cardPath = $derived(agent)
// The version eval runs are recorded against. The resource does not hold it; its newest history
// entry does, since recording is a database trigger on every write.
let versionResource = resource(
() => ({ ws, path: cardPath, writes }),
async ({ ws, path }): Promise<{ ws?: string; path?: string; version?: number }> => {
async ({
ws,
path,
writes
}): Promise<{ ws?: string; path?: string; writes: number; version?: number }> => {
if (!ws || !path) {
return { ws, path }
return { ws, path, writes }
}
const history = await ResourceService.getResourceHistory({ workspace: ws, path })
return { ws, path, version: history.versions?.[0]?.version }
return { ws, path, writes, version: history.versions?.[0]?.version }
}
)
// Guarded like the link above: a response for a previous agent must not label this one.
// Guarded like the link above, `writes` included: a response for a previous agent must not label
// this one, and one from before a deploy must not relabel it with the version it replaced.
let version = $derived.by(() => {
const loaded = versionResource.current
return loaded !== undefined && loaded.ws === ws && loaded.path === cardPath
return loaded !== undefined &&
loaded.ws === ws &&
loaded.path === cardPath &&
loaded.writes === writes
? loaded.version
: undefined
})
@@ -184,7 +192,7 @@
if (loaded) {
claimLinkedToolsFetch(toolScope, moduleId)
// linkedResource types tools loosely; they are the same resource tools the store holds.
setLinkedAgentTools(toolScope, moduleId, loaded.tools as AgentToolStrict[])
setLinkedAgentTools(toolScope, moduleId, loaded.tools as AgentToolStrict[], agent)
publishedFor = agent
} else if (publishedFor !== undefined && publishedFor !== agent) {
// The link moved and the new agent hasn't resolved, so the stored tools are the old one's.
@@ -203,7 +211,7 @@
let showDetail = $state(false)
function openSave() {
newPath = editingPath ?? ''
newPath = ''
pathError = ''
description = ''
saveDrawer?.openDrawer()
@@ -260,12 +268,12 @@
// every brain transform and the tools. Comparing the saved config instead would miss a
// non-static brain edit, which the resource cannot hold yet linking still strips.
const savedSnapshot = discardedOnLinkSnapshot()
// If the edit session ends or changes while the requests below are in flight (Cancel, undo,
// session-draft sync, a different agent opened for editing), the resource is still written but
// the step must not be relinked/cleared. Pinning the path — not merely "some edit is active" —
// is what distinguishes this session from a replacement one.
// If the step is replaced while the requests below are in flight (undo, a session-draft sync),
// the resource is still written but the step must not be relinked and emptied. The tools array
// this save started from is what identifies it, and its link answers for the case where a
// step keeps that array yet is pointed at an agent of its own meanwhile.
const forkMarker = tools
const savingEditPath = getAgentEditingPath(forkMarker)
const startedUnlinkedFrom = agent
const exists = await ResourceService.existsResource({ workspace: ws!, path })
if (exists) {
// The drawer's path check is debounced, so a fast save can reach here with an unrelated
@@ -294,15 +302,8 @@
}
// The write minted a version, and nothing else the fetch keys on has to change for it to be
// the one the card should now be naming.
writes++
// Editing: a content-preserving refresh may have re-anchored the marker onto a clone of
// `tools`, which is still this session; a cleared or different path is not. Saving a
// standalone step has no marker to track, so only the fork's own array identifies it.
const sameSession =
savingEditPath === undefined
? tools === forkMarker
: getAgentEditingPath(tools) === savingEditPath
if (!sameSession) {
markAgentWritten(ws, path)
if (tools !== forkMarker || agent !== startedUnlinkedFrom) {
// The resource is written either way; say so, or the drawer just closes with no outcome.
sendUserToast(
`Saved ${path}, but the step changed while saving, so it was not linked to the agent`,
@@ -320,9 +321,6 @@
return false
}
agent = path
// Clear the edit entry while `tools` is still the fork's marker, before it's reassigned.
setAgentEditingPath(tools, undefined)
setAgentEditingPath(forkMarker, undefined)
// The brain + tools now live in the resource; a linked step keeps only the flow-local inputs.
tools = []
inputTransforms = flowLocalInputs(inputTransforms)
@@ -335,12 +333,11 @@
}
saving = true
try {
const updating = newPath === editingPath
const linked = await persist(newPath, description)
saveDrawer?.closeDrawer()
if (linked) {
logReusableAgentUsage(updating ? 'updated' : 'saved')
sendUserToast(updating ? `Updated agent ${newPath}` : `Saved reusable agent ${newPath}`)
logReusableAgentUsage('saved')
sendUserToast(`Saved reusable agent ${newPath}`)
}
} catch (e) {
sendUserToast(`Failed to save agent: ${e}`, true)
@@ -349,34 +346,13 @@
}
}
// Save the forked-for-edit step back to the agent it came from, updating it in place.
async function saveChanges() {
if (!ws || !editingPath) {
return
}
saving = true
const path = editingPath
try {
if (await persist(path)) {
logReusableAgentUsage('updated')
sendUserToast(`Updated agent ${path}`)
}
} catch (e) {
sendUserToast(`Failed to update agent: ${e}`, true)
} finally {
saving = false
}
}
// Copy the resource's brain + tools into the step, for Unlink (diverge here) and Edit (change the
// saved agent). Unlink folds this flow's tool_inputs into the tools and clears them, so the
// standalone step keeps its bindings; Edit must not fold, or those overrides would be promoted
// into the shared agent instead of surviving the re-link.
async function forkFromResource(
foldOverrides: boolean
): Promise<{ path: string; deployedConfig: string } | undefined> {
// Copy the resource's brain + tools into the step, so it can diverge from the agent it was
// linked to. This flow's tool_inputs are folded into the tools and then cleared, so the
// standalone step keeps the bindings it was running with.
// Returns false when the step changed under the fetch, so the caller can say nothing happened.
async function forkFromResource(): Promise<boolean> {
if (!ws || !agent) {
return undefined
return false
}
const path = agent
// `tools` is one array per module value, so it identifies the step itself — the path alone
@@ -384,9 +360,9 @@
const stepMarker = tools
const res = await ResourceService.getResource({ workspace: ws, path })
// The module may have been replaced while the fetch was in flight (undo, session drafts);
// applying a stale fork would overwrite the restored state and recreate the Editing target.
// applying a stale fork would overwrite the restored state.
if (agent !== path || tools !== stepMarker) {
return undefined
return false
}
const cfg = (res.value ?? {}) as AIAgentConfig
// Preserve the flow-local inputs already wired in the step.
@@ -398,30 +374,24 @@
}
const forkedInputs = { ...agentConfigToInputTransforms(cfg), ...local }
const forkedTools = cfg.tools ?? []
// The baseline edits are judged against, in the form `currentConfig` takes: comparing
// against the resource's own JSON would count key order as an edit.
const deployedConfig = JSON.stringify(agentConfigAsEdited(forkedInputs, forkedTools))
inputTransforms = forkedInputs
if (foldOverrides) {
for (const tool of forkedTools) {
const overrides = toolInputs?.[tool.id]
if (overrides && tool.value?.input_transforms) {
tool.value.input_transforms = { ...tool.value.input_transforms, ...overrides }
}
for (const tool of forkedTools) {
const overrides = toolInputs?.[tool.id]
if (overrides && tool.value?.input_transforms) {
tool.value.input_transforms = { ...tool.value.input_transforms, ...overrides }
}
toolInputs = {}
}
toolInputs = {}
tools = forkedTools
agent = undefined
return { path, deployedConfig }
return true
}
// Unlink forks the agent into this step so it can diverge here. It does not write back.
async function unlink() {
try {
const fork = await forkFromResource(true)
if (fork) {
setAgentEditingPath(tools, undefined)
const forked = await forkFromResource()
if (forked) {
logReusableAgentUsage('unlinked')
sendUserToast('Forked agent. Its configuration was copied into this step')
} else {
@@ -432,93 +402,18 @@
}
}
// Edit the saved agent itself: fork it into the step for editing, remembering the path so
// "Save changes" writes back to it (updating every flow that links to it).
async function editAgent() {
try {
const fork = await forkFromResource(false)
if (fork) {
const { path, ...baselines } = fork
setAgentEditingPath(tools, path, baselines)
sendUserToast(`Editing ${path}. Make changes, then Save changes to update it`)
} else {
sendUserToast('The step changed while loading the agent. Try Edit again', true)
}
} catch (e) {
sendUserToast(`Failed to edit agent: ${e}`, true)
}
}
/** The edits as a run of them executes them: the step's brain transforms as authored
* (expressions included) and its tools. Flow-local inputs are left out: a case supplies them,
* and that is what lets the server recognise a run of later-deployed edits as that version. */
function editedConfig(): AgentDraft {
const brain: Record<string, InputTransform> = {}
for (const [key, transform] of Object.entries(inputTransforms ?? {})) {
if (!(AGENT_FLOW_LOCAL_KEYS as readonly string[]).includes(key)) {
brain[key] = transform
}
}
return {
input_transforms: $state.snapshot(brain) as Record<string, unknown>,
tools: $state.snapshot(tools) as Record<string, unknown>[]
}
}
/** The configuration the step holds now, in the form it is compared with the deployed agent in.
* Expressions count: the saved config cannot hold one, but a run of the edits executes it and
* Cancel drops it. */
let currentConfig = $derived(JSON.stringify(agentConfigAsEdited(inputTransforms, tools)))
/** The agent as deployed, in the same form; kept on the edit session so an editor mounted
* part-way through still has it. */
let deployedConfig = $derived(editing?.deployedConfig)
let edited = $derived(deployedConfig !== undefined && currentConfig !== deployedConfig)
let diffDrawer: DiffDrawer | undefined = $state()
// Both sides snapshotted at click time: the drawer would otherwise keep re-reading the live
// step while the user types behind it.
function showDiff() {
if (!deployedConfig) return
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: JSON.parse(deployedConfig) as Value,
current: $state.snapshot(agentConfigAsEdited(inputTransforms, tools)) as Value,
title: 'Deployed <> Unsaved agent changes',
button: {
text: 'Discard changes',
onClick: () => {
// Asked for by name, from a drawer showing exactly what goes: no second question.
const path = editingPath
if (path) relink(path)
diffDrawer?.closeDrawer()
}
}
// Edit the saved agent itself. The step stays linked throughout: the edits live in the agent's
// own resource draft, not in this step, so they survive leaving the flow and are the same edits
// whichever flow — or the resources page — opened them.
function editAgent() {
if (!agent) return
openAgentEditor({
path: agent,
workspace: ws,
// Where to re-resolve this graph's tool nodes once the agent is deployed.
host: { flowPath, moduleId }
})
}
let confirmCancel = $state(false)
// Cancel drops the edits and re-links the step without saving anything, so it asks first when
// there are edits to drop.
function cancelEdit() {
const path = editingPath
if (!path) return
if (edited) {
confirmCancel = true
return
}
relink(path)
}
// Put the step back on the agent. Edit kept this flow's `tool_inputs` off the forked tools
// rather than folding them in, so they survive the round trip as overrides.
function relink(path: string) {
// Clear the entry while `tools` is still the fork's array, which is what keys it.
setAgentEditingPath(tools, undefined)
agent = path
tools = []
inputTransforms = flowLocalInputs(inputTransforms)
}
</script>
<div class="px-2 xl:px-4 py-1.5 border-b border-light">
@@ -564,29 +459,19 @@
{/if}
</span>
{/if}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: FlaskConical }}
title="Run this agent against a dataset of cases"
onclick={(e) => {
e.stopPropagation()
evalsOpen = true
}}
>
Evals
</Button>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Pencil }}
iconOnly
title="Edit the saved agent (updates it everywhere it's used)"
onclick={(e) => {
e.stopPropagation()
editAgent()
}}
/>
{#if !fromAgentEditor}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Pencil }}
iconOnly
title="Edit the saved agent (updates it everywhere it's used)"
onclick={(e) => {
e.stopPropagation()
editAgent()
}}
/>
{/if}
<Button
unifiedSize="sm"
variant="default"
@@ -630,73 +515,7 @@
</Alert>
</div>
{/if}
{:else if editingPath}
<div
class="rounded-md border border-light bg-surface-tertiary px-3 py-1.5 flex flex-col gap-1.5"
>
<div class="flex items-center gap-2">
<Pencil size={16} class="text-primary shrink-0" />
<div class="min-w-0 flex-1 flex flex-col">
<div class="flex items-center gap-2 min-w-0">
<span class="truncate text-xs font-medium" title={editingPath}>{editingPath}</span>
{#if version != undefined}
<Badge color="gray" class="shrink-0" title="The version these edits sit on">
v{version}
</Badge>
{/if}
{#if edited}
<Badge
color="yellow"
class="shrink-0"
clickable
title="Show what differs from the deployed agent"
onclick={showDiff}
>
unsaved changes
</Badge>
{/if}
</div>
<div class="text-2xs text-secondary flex items-center gap-0.5">
saving updates every flow using it<Tooltip small>
{#snippet text()}
The edits live in this step until you decide: Evals runs them as they are here, Save
changes writes them to the agent, Cancel drops them and re-links the step.
{/snippet}
</Tooltip>
</div>
</div>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: FlaskConical }}
title="Run these edits against a dataset of cases"
disabled={saving}
onclick={() => (evalsOpen = true)}
>
Evals
</Button>
</div>
<div class="flex items-center justify-end gap-1">
<Button unifiedSize="sm" variant="default" disabled={saving} onclick={cancelEdit}>
Cancel
</Button>
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Save }}
disabled={saving || !!providerSaveError}
onclick={saveChanges}
>
Save changes
</Button>
</div>
</div>
{#if providerSaveError}
<p class="text-2xs text-red-500 mt-1">
{providerSaveError}
</p>
{/if}
{:else}
{:else if !fromAgentEditor}
<Button
unifiedSize="sm"
variant="default"
@@ -752,31 +571,3 @@
{/snippet}
</DrawerContent>
</Drawer>
<!-- Opened from the editing card, the edits are what is under test, read from the step when Run is
pressed; from the linked card, the deployed agent is. -->
<AgentEvalModal
agentPath={cardPath}
{opWorkspace}
editedConfig={editingPath ? editedConfig : undefined}
bind:open={evalsOpen}
/>
<DiffDrawer bind:this={diffDrawer} />
<ConfirmationModal
open={confirmCancel}
title="Drop these edits?"
confirmationText="Drop edits"
onConfirmed={() => {
confirmCancel = false
const path = editingPath
if (path) relink(path)
}}
onCanceled={() => (confirmCancel = false)}
>
<span class="text-sm">
The step goes back to {editingPath} as it is deployed, and the edits are not kept anywhere. Save
changes writes them to the agent instead.
</span>
</ConfirmationModal>
@@ -4,11 +4,13 @@
import type { InputTransform, ScriptLang } from '$lib/gen'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import { Wrench } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { ChevronDown, ChevronRight, Wrench } from 'lucide-svelte'
import type { PickableProperties } from '../previousResults'
import {
agentToolToFlowModule,
isFlowModuleTool,
AI_AGENT_TOOL_AI_KEYS,
type AgentTool,
type FlowModuleTool
} from '../agentToolUtils'
@@ -129,14 +131,44 @@
const v = tool.value as { type?: string; language?: ScriptLang; content?: string }
return v.type === 'rawscript' && v.content ? v : undefined
}
/** How many of this tool's inputs this flow binds. `toolInputs` holds the diff from the
* resource, so its size is the count. */
function overrideCount(tool: FlowModuleTool): number {
return Object.keys(toolInputs?.[tool.id] ?? {}).length
}
// Which sections the reader opened or closed. Absent means the default below, so a tool that
// gains an override while untouched still opens on its own.
let toggled = $state<Record<string, boolean>>({})
/** Open where there is something to see: a tool this flow binds. The rest are the agent's own
* business, and a linked agent's roster is mostly that. */
function isOpen(tool: FlowModuleTool): boolean {
return toggled[tool.id] ?? overrideCount(tool) > 0
}
</script>
{#if flowTools.length > 0}
<div class="flex flex-col gap-2 px-2 pb-8 xl:px-4">
{#each flowTools as tool (tool.id)}
{@const code = toolCode(tool)}
{@const open = isOpen(tool)}
<div class="rounded-md border border-light">
<div class="flex items-center gap-1.5 border-b border-light px-3 py-1.5 text-xs">
<Button
variant="subtle"
unifiedSize="sm"
aria-expanded={open}
onClick={() => (toggled[tool.id] = !open)}
wrapperClasses="w-full"
btnClasses="w-full !justify-start !text-left !px-3 !font-normal !text-xs !gap-1.5 !rounded-b-none {open
? 'border-b border-light'
: ''}"
>
{#if open}
<ChevronDown size={14} class="shrink-0 text-tertiary" />
{:else}
<ChevronRight size={14} class="shrink-0 text-tertiary" />
{/if}
<Wrench size={14} class="shrink-0 text-tertiary" />
<span class="font-medium">{tool.summary || tool.id}</span>
{#if tool.description}
@@ -144,8 +176,10 @@
>{tool.description}</span
>
{/if}
</div>
{#if schemas[tool.id] !== undefined && localArgs[tool.id] !== undefined}
</Button>
{#if !open}
<!-- collapsed: the agent's own inputs, nothing this flow has changed -->
{:else if schemas[tool.id] !== undefined && localArgs[tool.id] !== undefined}
<InputTransformSchemaForm
class="px-3 pt-1"
schema={schemas[tool.id]}
@@ -153,6 +187,9 @@
{extraLib}
{workspace}
isAgentTool
allowedAiTransforms={(tool.value as { type?: string }).type === 'aiagent'
? AI_AGENT_TOOL_AI_KEYS
: undefined}
bind:args={
() => localArgs[tool.id],
(v) => {
@@ -163,7 +200,7 @@
{:else}
<div class="px-3 py-2 text-2xs text-tertiary">Loading inputs...</div>
{/if}
{#if code}
{#if open && code}
<details class="border-t border-light">
<summary class="cursor-pointer px-3 py-1.5 text-2xs font-medium text-tertiary">
Tool code (read-only)
@@ -0,0 +1,172 @@
<script lang="ts">
import { ChevronRight, Plus, Wrench, X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { getToolNameError, toolDisplayName } from '../agentToolUtils'
import type { AgentTool } from '../agentToolUtils'
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import InsertModuleInner from '../map/InsertModuleInner.svelte'
import { overlayPortalTarget } from '$lib/components/common/overlayHost.svelte'
interface Props {
tools?: AgentTool[]
/** Where clicking a tool goes. Without it the roster is a read-only list. */
onSelectTool?: (toolId: string) => void
/** Adds a tool. Without it the empty state only says where to add one. */
onAddTool?: (detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) => void
/** Removes a tool. Without it the rows carry no delete, as they must not for a linked
* agent's tools, which belong to the agent rather than to the step showing them. */
onDeleteTool?: (toolId: string) => void
emptyMessage?: string
/** Where the picker's popover belongs, when the roster is not inside the flow editor. */
pickerPortal?: string
}
let {
tools = [],
onSelectTool = undefined,
onAddTool = undefined,
onDeleteTool = undefined,
emptyMessage = 'No tools yet. Add one from the agent on the flow graph.',
pickerPortal = '#flow-editor'
}: Props = $props()
let funcDesc = $state('')
const portalTarget = overlayPortalTarget(() => pickerPortal)
function toolKind(tool: AgentTool): string | undefined {
const value = tool?.value as Record<string, any>
if (value?.type === 'aiagent') return 'agent'
if (value?.tool_type === 'mcp') return 'MCP'
if (value?.tool_type === 'websearch') return 'web search'
return undefined
}
/** Only a flow module tool's summary is the name the model is given; an mcp or websearch
* summary is never sent, so a blank one there is not an error. Same rule as `agentToolTree`. */
function isNamed(tool: AgentTool): boolean {
return tool?.value?.tool_type !== 'mcp' && tool?.value?.tool_type !== 'websearch'
}
let siblingNames = $derived(tools.filter(isNamed).map((tool) => tool?.summary ?? ''))
function nameError(tool: AgentTool): string | undefined {
if (!isNamed(tool)) return undefined
return getToolNameError(tool?.summary ?? '', tool?.value?.tool_type, siblingNames)
}
/** What to call a tool that has nothing to be called yet. A kind that carries no name says what
* it is still missing instead; the rest are named by their problem, since the run cannot start
* without one. There is room here for the full wording the graph node has to abbreviate. */
function unnamedLabel(tool: AgentTool): string {
if (tool?.value?.tool_type === 'mcp') return 'No MCP server selected'
return 'Missing tool name'
}
</script>
{#snippet addToolButton()}
<Popover
portal={portalTarget()}
contentClasses="p-2 max-w-full h-[400px] bg-surface"
class="inline-block"
usePointerDownOutside
floatingConfig={{ placement: 'bottom-start', strategy: 'absolute', gutter: 8, flip: true }}
>
{#snippet trigger()}
<Button variant="default" unifiedSize="sm" startIcon={{ icon: Plus }}>Tool</Button>
{/snippet}
{#snippet content({ close })}
<!-- The same picker the graph's `+ Tool` opens, so both routes offer the same kinds and
hand the same detail to the same insert. -->
<InsertModuleInner
bind:funcDesc
toolMode
on:close={close}
on:new={(e) => (onAddTool?.(e.detail), close())}
on:insert={(e) => (onAddTool?.(e.detail), close())}
on:pickScript={(e) => (
onAddTool?.({
kind: e.detail.kind,
script: {
...e.detail,
summary: e.detail.summary
? e.detail.summary.replace(/\s/, '_').replace(/[^a-zA-Z0-9_]/g, '')
: e.detail.path.split('/').pop()
}
}),
close()
)}
on:pickMcpTool={() => (onAddTool?.({ kind: 'mcpTool' }), close())}
on:pickWebsearchTool={() => (onAddTool?.({ kind: 'websearchTool' }), close())}
on:pickAiAgentTool={() => (onAddTool?.({ kind: 'aiAgentTool' }), close())}
/>
{/snippet}
</Popover>
{/snippet}
{#if tools.length === 0}
<div class="flex flex-col items-start gap-2">
<div class="text-xs text-tertiary">
{onAddTool ? 'No tools yet.' : emptyMessage}
</div>
{#if onAddTool}
{@render addToolButton()}
{/if}
</div>
{:else}
<div class="flex flex-col border rounded-md divide-y overflow-hidden">
<!-- Keyed by position, not id: the list is JSON-authored, so two entries can carry the same
id or none, and a duplicate key throws during render. The rows hold no state of their
own, so position is identity enough here. -->
{#each tools as tool, i (i)}
{@const kind = toolKind(tool)}
{@const error = nameError(tool)}
<!-- The delete sits beside the row rather than inside it: the row is itself a button, and
a button cannot hold another. -->
<div class="flex flex-row items-stretch">
<Button
variant="subtle"
unifiedSize="sm"
disabled={!onSelectTool}
onClick={() => onSelectTool?.(tool?.id)}
wrapperClasses="grow min-w-0"
btnClasses="w-full min-w-0 !justify-start !text-left !px-2 !font-normal !text-xs text-primary"
>
<Wrench size={13} class="shrink-0 text-tertiary" />
<!-- A tool the worker will reject is named by its problem, not by an id no one
chose: the id would read as a name and hide that the run cannot start. -->
<span class={twMerge('truncate shrink min-w-0', error && 'text-red-400')}>
{error
? tool?.summary || unnamedLabel(tool)
: (toolDisplayName(tool) ?? unnamedLabel(tool))}
</span>
{#if error && tool?.summary}
<span class="truncate grow min-w-0 text-2xs text-red-400">{error}</span>
{:else}
<span class="grow"></span>
{/if}
{#if kind}
<span class="text-2xs text-tertiary shrink-0">{kind}</span>
{/if}
{#if onSelectTool}
<ChevronRight size={13} class="shrink-0 text-tertiary" />
{/if}
</Button>
{#if onDeleteTool}
<Button
unifiedSize="2xs"
variant="subtle"
iconOnly
startIcon={{ icon: X }}
title="Delete {toolDisplayName(tool) ?? 'this tool'}"
wrapperClasses="shrink-0 self-center pr-1"
onclick={() => onDeleteTool?.(tool?.id)}
/>
{/if}
</div>
{/each}
</div>
{#if onAddTool}
<div class="mt-2">{@render addToolButton()}</div>
{/if}
{/if}
@@ -15,6 +15,11 @@
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
siblingToolNames?: string[]
/** See `FlowModuleComponent`: set when the tool belongs to a saved agent rather than to a
* step of this flow. */
staticOnly?: boolean
/** See `FlowModuleComponent`: set where there is no graph to select a nested tool on. */
noToolNavigation?: boolean
}
let {
@@ -25,15 +30,28 @@
previousModule = undefined,
forceTestTab,
highlightArg,
siblingToolNames = undefined
siblingToolNames = undefined,
staticOnly = false,
noToolNavigation = false
}: Props = $props()
</script>
{#if isFlowModuleTool(tool)}
<!-- FlowModule tool - use existing FlowModuleComponent -->
<!-- "Save to workspace" and "Fork" replace the module wholesale, so the binding must be
two-way or the deploy writes the pre-replacement tool. Merged rather than assigned: they
build a plain step, which carries none of the fields only a tool has. -->
<FlowModuleComponent
{noEditor}
flowModule={tool as FlowModule}
bind:flowModule={
() => tool as FlowModule,
(v) =>
(tool = {
...tool,
...v,
value: { tool_type: tool.value?.tool_type, ...v.value }
} as unknown as AgentTool)
}
{parentModule}
{previousModule}
failureModule={false}
@@ -45,6 +63,8 @@
forceTestTab={forceTestTab?.[tool.id]}
highlightArg={highlightArg?.[tool.id]}
isAgentTool={true}
{staticOnly}
{noToolNavigation}
bind:toolDescription={tool.description}
{siblingToolNames}
/>
@@ -0,0 +1,394 @@
<script module lang="ts">
// A field added but not yet filled looks exactly like an unset one, so leaving the step and
// coming back would drop the row. Remembering which fields are open per step keeps it. Bounded,
// or a long-lived tab keeps one entry for every agent step it ever opened.
const openFieldsByStep = new Map<string, string[]>()
const MAX_REMEMBERED_STEPS = 50
function rememberOpenFields(key: string | undefined, keys: string[]) {
if (!key) return
openFieldsByStep.delete(key)
openFieldsByStep.set(key, keys)
while (openFieldsByStep.size > MAX_REMEMBERED_STEPS) {
const oldest = openFieldsByStep.keys().next().value
if (oldest === undefined) break
openFieldsByStep.delete(oldest)
}
}
</script>
<script lang="ts">
import type { Schema } from '$lib/common'
import { type InputTransform } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
import { getContext, untrack } from 'svelte'
import { SvelteSet } from 'svelte/reactivity'
import { Button } from '$lib/components/common'
import StepInputsGen from '$lib/components/copilot/StepInputsGen.svelte'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import InputTransformPickers from '$lib/components/InputTransformPickers.svelte'
import { useS3StorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import type ItemPicker from '$lib/components/ItemPicker.svelte'
import type VariableEditor from '$lib/components/VariableEditor.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
import { Plus, X } from 'lucide-svelte'
import type { PickableProperties } from '../previousResults'
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
import type { AgentTool } from '../agentToolUtils'
import {
AGENT_FIELDS,
AGENT_FIELD_GROUPS,
agentFieldAppliesTo,
initialVisibleAgentFields,
type AgentFieldGroup,
type AgentFieldSpec
} from '../agentFormFields'
import AgentToolRoster from './AgentToolRoster.svelte'
interface Props {
schema: Schema | { properties?: Record<string, any> }
/** Required: every row below indexes it directly, and a first render against an unset one
* would throw before the effect that normalises it could run. */
args: Record<string, InputTransform | any>
isValid?: boolean
extraLib?: string
previousModuleId?: string | undefined
/** Restrict the form to these keys, for a surface that edits only part of an agent. */
filter?: string[] | undefined
pickableProperties?: PickableProperties | undefined
enableAi?: boolean
class?: string
helperScript?: DynamicInputTypes.HelperScript
isAgentTool?: boolean
allowedAiTransforms?: string[] | undefined
chatInputEnabled?: boolean
workspace?: string | undefined
/** Identifies the step, so the fields it has open survive leaving and coming back. */
visibilityKey?: string
tools?: AgentTool[]
/** Offer only static values, for a surface whose store cannot hold anything else — a saved
* agent's config is plain JSON, so an expression on a brain field would be dropped on save.
* A step's own agent has no such limit: its transforms are evaluated per run. */
staticOnly?: boolean
/** Show the agent without letting anything about it be changed, for a viewer who has read
* access only. The fields are made inert rather than merely losing their buttons: a field
* left editable would look saved while every write behind it is rejected. */
readOnly?: boolean
/** Passed to every field, for a nested agent used as a tool: its brain has to stand on its
* own as `staticOnly` does, but its `user_message` is still the agent's to fill. */
noConnect?: boolean
noJavascript?: boolean
onSelectTool?: (toolId: string) => void
/** Adds a tool to this agent. Without it the roster is read-only, as it is for a linked
* agent, whose tools belong to the agent rather than to the step. */
onAddTool?: (detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) => void
/** Removes a tool from this agent. Same rule as `onAddTool`: absent where the tools are not
* the step's to change. */
onDeleteTool?: (toolId: string) => void
/** Where the tool picker's popover belongs, for a surface that is not the flow editor. */
toolPickerPortal?: string
}
let {
schema = $bindable(),
args = $bindable(),
isValid = $bindable(),
extraLib = $bindable(),
previousModuleId = undefined,
filter = undefined,
pickableProperties = undefined,
enableAi = false,
class: clazz = '',
helperScript = undefined,
isAgentTool = false,
// Reproduces InputTransformSchemaForm's own default rather than the call site's value:
// `undefined` lets any field become an AI transform, and InputTransformForm rewrites
// `arg.type` to 'ai' on mount for every static-undefined field when it does.
allowedAiTransforms = isAgentTool ? undefined : [],
chatInputEnabled = false,
workspace,
visibilityKey = undefined,
tools = [],
staticOnly = false,
readOnly = false,
noConnect = false,
noJavascript = false,
onSelectTool = undefined,
onAddTool = undefined,
onDeleteTool = undefined,
toolPickerPortal = undefined
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let inputCheck: { [id: string]: boolean } = $state({})
$effect(() => {
isValid = allTrue(inputCheck) ?? false
})
$effect(() => {
if (args == undefined || typeof args !== 'object') {
args = {}
}
})
export function setArgs(nargs: Record<string, InputTransform | any>) {
args = nargs
}
let pickForField: string | undefined = $state()
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const s3Storage = useS3StorageConfigured(() => ws)
// The per-field copilot only ever writes a JavaScript transform, so it belongs only where one can
// be stored. On a static-only field the write lands in a key the config drops on deploy, which
// reports success over a visible edit, or persists an expression into a saved agent that every
// flow linking it would then have to satisfy.
let fieldAiEnabled = $derived(enableAi && !staticOnly && !noJavascript)
let schemaProperties = $derived((schema?.properties ?? {}) as Record<string, any>)
let scopedFields = $derived(
AGENT_FIELDS.filter(
(spec) =>
agentFieldAppliesTo(spec, schemaProperties) && (!filter || filter.includes(spec.key))
)
)
let outputType = $derived.by(() => {
const transform = args?.['output_type']
return transform && transform.type === 'static' ? transform.value : undefined
})
let imageOutput = $derived(outputType === 'image')
// Which fields have a row. Never derived from `args`, or emptying a textbox would make its row
// vanish under the cursor: this only ever grows, and the x is the one thing that shrinks it.
// Read once, on purpose: the set is seeded here and grows from there, so re-reading the key would
// be a rebuild that throws away everything the user has opened.
let visible = $state(
new SvelteSet<string>(openFieldsByStep.get(untrack(() => visibilityKey) ?? '') ?? [])
)
// A field set from anywhere else — an undo, a schema that arrived late — brings its row back on
// its own.
$effect(() => {
const set = initialVisibleAgentFields(args, schemaProperties)
untrack(() => {
for (const key of set) visible.add(key)
})
})
// The copilot hands its expressions to whichever row is mounted for the argument, so a field it
// targets needs one before it can take the value. Watched here rather than left to the union
// above, which only sees a field once something has already written to it.
const { exprsToSet } = getContext<FlowCopilotContext | undefined>('FlowCopilotContext') ?? {}
let pendingExprKeys = $derived(
exprsToSet ? Object.keys($exprsToSet ?? {}).filter((key) => $exprsToSet?.[key]) : []
)
$effect(() => {
const keys = pendingExprKeys
untrack(() => {
for (const key of keys) {
if (key in schemaProperties) visible.add(key)
}
})
})
$effect(() => {
const keys = [...visible]
untrack(() => rememberOpenFields(visibilityKey, keys))
})
function rowsIn(group: AgentFieldGroup): AgentFieldSpec[] {
return scopedFields.filter(
(spec) => spec.group === group && visible.has(spec.key) && !(imageOutput && spec.textOnly)
)
}
function addableIn(): AgentFieldSpec[] {
return scopedFields.filter(
(spec) =>
!spec.core && !spec.virtual && !visible.has(spec.key) && !(imageOutput && spec.textOnly)
)
}
function addField(spec: AgentFieldSpec) {
// `flowInfers` re-seeds every key on load, so adding cannot mean creating the key: it means
// showing the row, seeded at what a run does today so the field opens on what it overrides.
if (args) {
args[spec.key] = { type: 'static', value: structuredClone(spec.implicit) }
}
visible.add(spec.key)
}
function removeField(spec: AgentFieldSpec) {
visible.delete(spec.key)
if (args) {
// Back to exactly what `flowInfers` seeds, so removing a field leaves no diff behind.
// Never `delete args[key]`: the key returns on the next load, and the CLI linter requires
// `user_message` to be present.
args[spec.key] = { type: 'static', value: undefined }
}
// InputTransformSchemaForm leaks these on unmount, which would pin `isValid` false forever
// once hiding a row is routine.
delete inputCheck[spec.key]
}
let emptyArgNames = $derived(
[...visible].filter((key) => {
if (!(key in schemaProperties)) return false
const transform = args?.[key]
if (!transform) return false
return (
(transform.type === 'static' && !transform.value) ||
(transform.type === 'javascript' && !transform.expr)
)
})
)
</script>
{#snippet addFieldMenu()}
{@const candidates = addableIn()}
{#if candidates.length > 0}
<DropdownV2 placement="bottom-start" customMenu class="justify-start">
{#snippet buttonReplacement()}
<Button variant="default" unifiedSize="md" startIcon={{ icon: Plus }}>Add a field</Button>
{/snippet}
{#snippet menu({ close })}
<!-- Laid out as SelectDropdown lays out a resource list, so the two menus in this form
behave the same: full-width rows, and a bordered heading per group. -->
<div
class="flex flex-col w-80 rounded-md bg-surface-input drop-shadow-base overflow-y-auto max-h-[50vh]"
>
{#each AGENT_FIELD_GROUPS as menuGroup (menuGroup.id)}
{@const groupCandidates = candidates.filter((spec) => spec.group === menuGroup.id)}
{#if groupCandidates.length > 0}
<div class="px-4 pt-3 pb-1 text-2xs font-normal uppercase text-secondary">
{menuGroup.label}
</div>
{#each groupCandidates as spec (spec.key)}
<Button
variant="subtle"
unifiedSize="md"
onClick={() => {
addField(spec)
close()
}}
wrapperClasses="w-full"
btnClasses="w-full !h-auto !justify-start !text-left !py-2 !px-4 !font-normal !text-xs text-primary !rounded-none"
>
<div class="w-full">
{spec.label}
{#if spec.defaultHint}
<div class="text-2xs text-secondary">{spec.defaultHint}</div>
{/if}
</div>
</Button>
{/each}
{/if}
{/each}
</div>
{/snippet}
</DropdownV2>
{/if}
{/snippet}
<div class="w-full mb-6 {clazz}">
<!-- Not offered on a static-only surface, where what it fills a field with is a JavaScript
expression such a store cannot hold; nor on a tool, whose empty fields are the ones the
agent above fills at run time. -->
{#if enableAi && !staticOnly && !isAgentTool && !readOnly}
<div class="pt-2">
<StepInputsGen {pickableProperties} argNames={emptyArgNames} {schema} />
</div>
{/if}
<div class="flex flex-col gap-8 pt-4">
{#each AGENT_FIELD_GROUPS as group (group.id)}
{@const rows = rowsIn(group.id)}
{#if rows.length > 0}
<div class="w-full flex flex-col">
<h2 class="mb-1 text-2xs font-normal uppercase text-secondary">{group.label}</h2>
<div class="flex flex-col gap-6">
{#each rows as spec (spec.key)}
<ResizeTransitionWrapper innerClass="w-full" vertical>
{#if spec.virtual}
<AgentToolRoster
{tools}
{onSelectTool}
{onAddTool}
{onDeleteTool}
pickerPortal={toolPickerPortal}
/>
{:else}
<!-- Inert rather than merely button-less: every control below writes into
`args`, and a read-only viewer's edit is rejected by the server. Dimmed
with it, so a field that ignores a click looks like it meant to. -->
<div class="w-full {readOnly ? 'opacity-60' : ''}" inert={readOnly}>
<InputTransformForm
{previousModuleId}
bind:arg={args[spec.key]}
bind:schema
argName={spec.key}
label={spec.label}
headerTooltip={spec.tooltip}
hideDescription
subtleControls
argExtra={schemaProperties[spec.key] ?? {}}
bind:inputCheck={
() => inputCheck[spec.key] ?? false,
(value) => (inputCheck[spec.key] = value)
}
bind:extraLib={() => extraLib ?? 'missing extraLib', (v) => (extraLib = v)}
{variableEditor}
{itemPicker}
bind:pickForField
{pickableProperties}
enableAi={fieldAiEnabled}
{helperScript}
{isAgentTool}
{allowedAiTransforms}
noDynamicToggle={staticOnly}
noConnect={staticOnly || noConnect}
noJavascript={staticOnly || noJavascript}
s3StorageConfigured={s3Storage.current}
{chatInputEnabled}
{workspace}
otherArgs={Object.fromEntries(
Object.entries(args ?? {}).filter(([key]) => key !== spec.key)
)}
>
{#snippet labelExtra()}
{#if !spec.core && !readOnly}
<Button
variant="subtle"
unifiedSize="2xs"
iconOnly
startIcon={{ icon: X }}
wrapperClasses="ml-1"
title="Unset {spec.label}"
on:click={() => removeField(spec)}
/>
{/if}
{/snippet}
</InputTransformForm>
</div>
{/if}
</ResizeTransitionWrapper>
{/each}
</div>
</div>
{/if}
{/each}
{#if !readOnly}
{@render addFieldMenu()}
{/if}
</div>
</div>
<InputTransformPickers {args} {pickForField} {workspace} bind:itemPicker bind:variableEditor />
@@ -1,5 +1,6 @@
<script lang="ts">
import { Badge, Tab, Tabs } from '$lib/components/common'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { GripVertical, Plus, Trash2 } from 'lucide-svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
@@ -9,7 +10,6 @@
reorderBranches as reorderBranchesOp,
graphBranchIndex
} from '../branchOps'
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
@@ -83,7 +83,7 @@
function addBranch() {
addBranchOp(flowModule.id, { flowStore, history })
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
}
function removeBranch(arrayIndex: number) {
// The shared op counts branches the way the graph does; see graphBranchIndex.
@@ -92,7 +92,7 @@
flowStateStore,
history
})
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
}
let runSettings: FlowRunSettings | undefined = $state(undefined)
@@ -1,5 +1,6 @@
<script lang="ts">
import { Badge, Tab, Tabs } from '$lib/components/common'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { GripVertical, Plus, Trash2 } from 'lucide-svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
@@ -9,7 +10,6 @@
reorderBranches as reorderBranchesOp,
graphBranchIndex
} from '../branchOps'
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
@@ -90,7 +90,7 @@
function addBranch() {
addBranchOp(flowModule.id, { flowStore, history })
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
}
function removeBranch(arrayIndex: number) {
// The shared op counts branches the way the graph does; see graphBranchIndex.
@@ -99,7 +99,7 @@
flowStateStore,
history
})
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
}
let runSettings: FlowRunSettings | undefined = $state(undefined)
@@ -198,6 +198,7 @@
savedModule={savedModuleById(savedFlow?.value.modules, flowModule.id)}
{forceTestTab}
{highlightArg}
{flowModuleSchemaMap}
/>
{/each}
{/key}
@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { ButtonType } from '$lib/components/common/button/model'
import { getContext, tick, untrack } from 'svelte'
import FlowCard from '../common/FlowCard.svelte'
@@ -43,11 +44,11 @@
import SideBarTab from '$lib/components/meltComponents/SideBarTab.svelte'
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import { isObjectTooBig, readFieldsRecursively } from '$lib/utils'
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen'
import { deepEqual } from 'fast-equals'
import Toggle from '$lib/components/Toggle.svelte'
import { AI_AGENT_SCHEMA } from '../flowInfers'
import { agentStreamingEnabled } from '../agentFormFields'
import { nextId } from '../flowModuleNextId'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import FlowChat from '../conversations/FlowChat.svelte'
@@ -99,11 +100,8 @@
let shouldUseStreaming = $derived.by(() => {
const modules = flowStore.val.value?.modules
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
return (
lastModule?.value?.type === 'aiagent' &&
lastModule?.value?.input_transforms?.streaming?.type === 'static' &&
lastModule?.value?.input_transforms?.streaming?.value === true
)
if (lastModule?.value?.type !== 'aiagent') return false
return agentStreamingEnabled(lastModule.value)
})
let showChatModeWarning = $state(false)
let showAdditionalInputs = $state(false)
@@ -890,7 +888,7 @@
onAddNew={(argName) => {
handleEditSchema('inputEditor')
editableSchemaForm?.openField(argName)
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
}}
>
{#snippet trigger()}
@@ -1,5 +1,6 @@
<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import StepSettingsBadges from './StepSettingsBadges.svelte'
@@ -29,9 +30,11 @@
import FlowPathViewer from './FlowPathViewer.svelte'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
import AgentResourceBar from './AgentResourceBar.svelte'
import AiAgentStepInputs from './AiAgentStepInputs.svelte'
import AgentToolBindings from './AgentToolBindings.svelte'
import { getLinkedAgentTools, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
import { flowLocalAgentSchema } from '../agentResourceUtils'
import { AI_AGENT_TOOL_AI_KEYS } from '../agentToolUtils'
import DiffEditor from '$lib/components/DiffEditor.svelte'
import type { ButtonProp } from '$lib/components/diffEditorTypes'
import { loadSchemaFromModule } from '../flowInfers'
@@ -44,7 +47,6 @@
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
import AssetsDropdownButton from '$lib/components/assets/AssetsDropdownButton.svelte'
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
@@ -82,10 +84,10 @@
customUi,
executionCount,
opWorkspace,
agentEditorHost,
workspaceScriptSettingsDrawer
} = getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
// The agent editor's save guard uses the `tools` array identity to tell "same step" from "step
// replaced mid-save", so a module carrying no `tools` must read as one stable array — but a
// distinct one per module value, or a wholesale edit that keeps the module id would look
@@ -117,6 +119,17 @@
forceTestTab?: boolean
highlightArg?: string
isAgentTool?: boolean
/** Offer only values that stand on their own: no connect button, no expression option, no
* prop picker column. For a tool of a saved agent, whose inputs are stored on the resource
* and so cannot name any one flow's `flow_input` or `results`. A flow binds those on the
* step instead, through `tool_inputs`. */
staticOnly?: boolean
/** Lets the agent's Tools section add a tool through the graph's own insert path. */
flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default
/** Drop the tool roster's drill-in. Selecting a tool means selecting its graph node, so a
* surface without a graph — the agent editor, which addresses one tool at a time — would
* offer a row whose click lands nowhere. */
noToolNavigation?: boolean
toolDescription?: string | undefined
siblingToolNames?: string[]
}
@@ -135,6 +148,9 @@
forceTestTab = false,
highlightArg = undefined,
isAgentTool = false,
staticOnly = false,
flowModuleSchemaMap = undefined,
noToolNavigation = false,
toolDescription = $bindable(undefined),
siblingToolNames = undefined
}: Props = $props()
@@ -270,7 +286,8 @@
modulePreview?.runTestWithStepArgs()
}
}
let inputTransformSchemaForm: InputTransformSchemaForm | undefined = $state(undefined)
let inputTransformSchemaForm: { setArgs: (nargs: Record<string, any>) => void } | undefined =
$state(undefined)
let reloadError: string | undefined = $state(undefined)
async function reload(flowModule: FlowModule) {
@@ -338,7 +355,7 @@
let stepHistoryLoader = getStepHistoryLoaderContext()
function onSelectedIdChange() {
if (!flowStateStore?.val?.[selectedId]?.schema && flowModule) {
if (!flowStateStore?.val?.[flowModule.id]?.schema && flowModule) {
reload(flowModule)
}
}
@@ -395,7 +412,7 @@
)
$effect.pre(() => {
selectedId && untrack(() => onSelectedIdChange())
flowModule.id && untrack(() => onSelectedIdChange())
})
let parentLoop = $derived(
flowStore.val && flowModule ? checkIfParentLoop(flowStore.val, flowModule.id) : undefined
@@ -487,7 +504,7 @@
}
let preparedSqlQueries = usePreparedAssetSqlQueries(
() => flowGraphAssetsCtx?.val.sqlQueries[selectedId],
() => flowGraphAssetsCtx?.val.sqlQueries[flowModule.id],
() => opWs
)
@@ -843,7 +860,7 @@
on:createScriptFromInlineScript={async () => {
const [module, state] = await createScriptFromInlineScript(
flowModule,
selectedId,
flowModule.id,
flowStateStore.val[flowModule.id]?.schema,
$pathStore,
opWs
@@ -937,13 +954,11 @@
automaticLayout={true}
cmdEnterAction={async () => {
selected = 'test'
if (selectedId == flowModule.id) {
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
}
await reload(flowModule)
modulePreview?.runTestWithStepArgs()
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
}
await reload(flowModule)
modulePreview?.runTestWithStepArgs()
}}
on:change={async (event) => {
const content = event.detail
@@ -1000,13 +1015,11 @@
automaticLayout={true}
cmdEnterAction={async () => {
selected = 'test'
if (selectedId == flowModule.id) {
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
}
await reload(flowModule)
modulePreview?.runTestWithStepArgs()
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
}
await reload(flowModule)
modulePreview?.runTestWithStepArgs()
}}
on:change={async (event) => {
const content = event.detail
@@ -1093,10 +1106,12 @@
}}
wrapperClass="shrink-0"
>
<!-- A tool's inputs are arguments of the tool, not of a step in the flow;
`tool_inputs` is what the rest of the codebase calls them. -->
{#if !preprocessorModule}
<Tab value="inputs" label="Step Input" />
<Tab value="inputs" label={isAgentTool ? 'Tool input' : 'Step Input'} />
{/if}
<Tab value="test" label="Test this step" />
<Tab value="test" label={isAgentTool ? 'Test this tool' : 'Test this step'} />
{#if canShowChatTab && flowModule.value.type === 'aiagent'}
<Tab
value="chat"
@@ -1114,10 +1129,13 @@
</Tabs>
{#if visibleSelected === 'inputs' && (flowModule.value.type == 'rawscript' || flowModule.value.type == 'script' || flowModule.value.type == 'flow' || flowModule.value.type == 'aiagent')}
<div class="flex-1 overflow-auto" id="flow-editor-step-input">
<!-- `sidePane` under `staticOnly`: that column only opens on a connect,
and there is no connect button to open it. -->
<PropPickerWrapper
pickableProperties={stepPropPicker.pickableProperties}
error={failureModule}
noPadding
sidePane={staticOnly}
>
{#if reloadError}
<div
@@ -1132,6 +1150,7 @@
moduleId={linkedToolsModuleId}
opWorkspace={opWs}
flowPath={$pathStore}
fromAgentEditor={agentEditorHost?.() != undefined}
bind:agent={
() =>
flowModule.value.type === 'aiagent'
@@ -1177,39 +1196,84 @@
}
/>
{/if}
<InputTransformSchemaForm
class="px-2 xl:px-4 pb-8"
bind:this={inputTransformSchemaForm}
pickableProperties={stepPropPicker.pickableProperties}
schema={agentLinked
? flowLocalAgentSchema(flowStateStore.val[selectedId]?.schema ?? {})
: (flowStateStore.val[selectedId]?.schema ?? {})}
previousModuleId={previousModule?.id}
bind:args={
() => {
// @ts-ignore
return flowModule?.value?.input_transforms
},
(v) => {
if (
typeof flowModule?.value === 'object' &&
flowModule?.value !== null
) {
{#if flowModule.value.type === 'aiagent'}
<AiAgentStepInputs
class="px-2 xl:px-4 pb-8"
bind:this={inputTransformSchemaForm}
pickableProperties={stepPropPicker.pickableProperties}
schema={agentLinked
? flowLocalAgentSchema(
flowStateStore.val[flowModule.id]?.schema ?? {}
)
: (flowStateStore.val[flowModule.id]?.schema ?? {})}
previousModuleId={previousModule?.id}
bind:args={
() => {
// @ts-ignore
flowModule.value.input_transforms = v
return flowModule?.value?.input_transforms
},
(v) => {
if (
typeof flowModule?.value === 'object' &&
flowModule?.value !== null
) {
// @ts-ignore
flowModule.value.input_transforms = v
}
}
}
}
extraLib={stepPropPicker.extraLib}
{enableAi}
{isAgentTool}
allowedAiTransforms={isAgentTool && flowModule.value.type === 'aiagent'
? ['user_message']
: undefined}
helperScript={retrieveDynCodeAndLang(flowModule.value)}
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
workspace={opWs}
/>
extraLib={stepPropPicker.extraLib}
{enableAi}
{isAgentTool}
noConnect={staticOnly}
noJavascript={staticOnly}
allowedAiTransforms={isAgentTool ? AI_AGENT_TOOL_AI_KEYS : undefined}
helperScript={retrieveDynCodeAndLang(flowModule.value)}
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
workspace={opWs}
visibilityKey={`${$pathStore}:${linkedToolsModuleId}`}
tools={flowModule.value.tools ?? []}
onSelectTool={noToolNavigation
? undefined
: (toolId) => selectionManager.selectId(toolId, { openPanel: true })}
onAddTool={flowModuleSchemaMap
? (detail) =>
flowModuleSchemaMap?.addToolToAgent(flowModule.id, detail)
: undefined}
/>
{:else}
<InputTransformSchemaForm
class="px-2 xl:px-4 pb-8"
bind:this={inputTransformSchemaForm}
pickableProperties={stepPropPicker.pickableProperties}
schema={flowStateStore.val[flowModule.id]?.schema ?? {}}
previousModuleId={previousModule?.id}
bind:args={
() => {
// @ts-ignore
return flowModule?.value?.input_transforms
},
(v) => {
if (
typeof flowModule?.value === 'object' &&
flowModule?.value !== null
) {
// @ts-ignore
flowModule.value.input_transforms = v
}
}
}
extraLib={stepPropPicker.extraLib}
{enableAi}
{isAgentTool}
noConnect={staticOnly}
noJavascript={staticOnly}
allowedAiTransforms={undefined}
helperScript={retrieveDynCodeAndLang(flowModule.value)}
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
workspace={opWs}
/>
{/if}
{#if agentLinked}
<!-- Linked agent: the resource's tools with their inputs rebindable to this
flow; overrides persist on the step as tool_inputs (diff from the resource). -->
@@ -1264,8 +1328,8 @@
mod={flowModule}
{noEditor}
schema={agentLinked
? flowLocalAgentSchema(flowStateStore.val[selectedId]?.schema ?? {})
: (flowStateStore.val[selectedId]?.schema ?? {})}
? flowLocalAgentSchema(flowStateStore.val[flowModule.id]?.schema ?? {})
: (flowStateStore.val[flowModule.id]?.schema ?? {})}
bind:testJob
bind:testIsLoading
bind:scriptProgress
@@ -1298,7 +1362,7 @@
{isAgentTool}
{parentModule}
{previousModule}
{selectedId}
selectedId={flowModule.id}
{referencedConcurrentLimit}
referencedConcurrencyTimeWindowS={referencedScriptSettings.settings
?.concurrency_time_window_s}
@@ -1391,7 +1455,7 @@
onUpdateMock={(detail) => {
flowModule.mock = detail
flowModule = flowModule
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
}}
{testJob}
{scriptProgress}
@@ -49,6 +49,8 @@
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
isAgentTool?: boolean
/** Lets an agent step add a tool through the same path as the graph's `+ Tool`. */
flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default
}
let {
@@ -60,7 +62,8 @@
previousModule = undefined,
forceTestTab,
highlightArg,
isAgentTool = false
isAgentTool = false,
flowModuleSchemaMap = undefined
}: Props = $props()
function initializePrimaryScheduleForTriggerScript(module: FlowModule) {
@@ -222,6 +225,7 @@
forceTestTab={forceTestTab?.[flowModule.id]}
highlightArg={highlightArg?.[flowModule.id]}
{isAgentTool}
{flowModuleSchemaMap}
/>
{/if}
{:else if flowModule.value.type === 'forloopflow' || flowModule.value.type == 'whileloopflow'}
@@ -232,6 +236,7 @@
child
)}
<FlowModuleWrapper
{flowModuleSchemaMap}
{noEditor}
bind:flowModule={slot.get, slot.set}
bind:parentModule={flowModule}
@@ -261,6 +266,7 @@
child
)}
<FlowModuleWrapper
{flowModuleSchemaMap}
{noEditor}
bind:flowModule={slot.get, slot.set}
bind:parentModule={flowModule}
@@ -285,6 +291,7 @@
{#each branch.modules as child, index (child.id ?? index)}
{@const slot = moduleSlot(() => branch.modules, child.id, child)}
<FlowModuleWrapper
{flowModuleSchemaMap}
{noEditor}
bind:flowModule={slot.get, slot.set}
bind:parentModule={flowModule}
@@ -305,6 +312,7 @@
{#each branch.modules as child, index (child.id ?? index)}
{@const slot = moduleSlot(() => branch.modules, child.id, child)}
<FlowModuleWrapper
{flowModuleSchemaMap}
{noEditor}
bind:flowModule={slot.get, slot.set}
bind:parentModule={flowModule}
@@ -1,4 +1,5 @@
import type { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { GroupedModulesProxy } from '$lib/components/graph/groupedModulesProxy.svelte'
import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
import type { FlowStructureNode } from '$lib/components/graph/flowStructure'
@@ -8,7 +9,6 @@ import type { StateStore } from '$lib/utils'
import { createDeletePlan, removeDeletePlanTools, type DeletePlan } from './flowDeleteUtils'
import type { FlowState } from './flowState'
import { deleteFlowStateById } from './flowStateUtils.svelte'
import { refreshFlowStateStore } from './flowStoreRefresh.svelte'
export type PreparedDeleteRequest = {
plan: DeletePlan
@@ -74,7 +74,7 @@ export function executeDeletePlan(
deleteFlowStateById(id, args.flowStateStore)
}
refreshFlowStateStore(args.flowStore)
refreshStateStore(args.flowStore)
if (plan.inputIds.length === 1) {
args.onDelete?.(plan.targets[0].id)
+20 -20
View File
@@ -15,29 +15,31 @@ export const AI_AGENT_SCHEMA: Schema = {
output_type: {
type: 'string',
description:
'The type of output the AI agent will generate (text or image). Image output requires a configured workspace S3 storage, will ignore tools, and only works with OpenAI, Google AI and OpenRouter gemini-image-preview model.',
'Whether the answer is text or an image. An image needs S3 storage on the workspace, and ignores tools.',
enum: ['text', 'image'],
default: 'text'
},
user_message: {
type: 'string',
description:
'The message to give as input to the AI agent. Optional when messages array is provided. You can turn on chat input mode on the input interface to link this field to the message sent by the user.'
description: 'The message sent to the agent as the user turn.'
},
system_prompt: {
type: 'string',
description: 'The system prompt to give as input to the AI agent.'
description: 'Sets how the agent behaves. Sent ahead of everything else.',
// The one field people write paragraphs into, so it opens as a text area.
minRows: 5,
placeholder:
"You are a support agent.\nLook up an answer with your tools before replying.\nCite what you used, and say you don't know rather than guessing."
},
streaming: {
type: 'boolean',
description: 'Whether to stream the output of the AI agent.',
description: 'Stream the answer as it is produced.',
default: true,
showExpr: "fields.output_type === 'text'"
showExpr: "fields.output_type !== 'image'"
},
memory: {
type: 'object',
description:
'Configure how conversation memory is managed. Choose "auto" to let Windmill automatically store and load messages (up to N last messages), or "manual" to provide an explicit array of conversation messages. The system_prompt and user_message are added to the messages if provided.',
description: 'History sent between the system message and the user message.',
oneOf: [
{
type: 'object',
@@ -133,18 +135,17 @@ export const AI_AGENT_SCHEMA: Schema = {
required: ['kind', 'messages']
}
],
showExpr: "fields.output_type === 'text'"
showExpr: "fields.output_type !== 'image'"
},
output_schema: {
type: 'object',
description: 'JSON schema that the AI agent will follow for its response format.',
description: 'A JSON schema the answer has to follow.',
format: 'json-schema',
showExpr: "fields.output_type === 'text'"
showExpr: "fields.output_type !== 'image'"
},
user_attachments: {
type: 'array',
description:
'Array of files (images or PDFs) to give as input to the AI agent. Requires a configured workspace S3 storage.',
description: 'Images or PDFs sent with the message. Needs S3 storage on the workspace.',
items: {
type: 'object',
resourceType: 's3object'
@@ -152,22 +153,21 @@ export const AI_AGENT_SCHEMA: Schema = {
},
max_completion_tokens: {
type: 'number',
description: 'The maximum number of output tokens.'
description: 'The most tokens the answer may use.'
},
temperature: {
type: 'number',
description:
'Controls randomness in text generation. Range: 0.0 (deterministic) to 2.0 (random).',
showExpr: "fields.output_type === 'text'"
description: 'How random the generation is, from 0 for deterministic up to 2.'
},
max_iterations: {
type: 'number',
description:
'Limits how many times the agent can loop through reasoning and tool use. Range: 1-1000.',
description: 'How many times the agent may loop over calling the model and running tools.',
default: 10
}
},
required: ['provider', 'output_type'],
// `output_type` defaults to text on the backend, so leaving it unset is valid: the form drops
// the row rather than showing a field whose value a run would ignore.
required: ['provider'],
type: 'object',
order: [
'provider',
@@ -92,8 +92,11 @@ async function mapFlowModule(
// resource tool ids are not flow-unique and must not key into the flow state.
await publishLinkedAgentTools(agentRef, workspace, scope, flowModule.id)
} else {
// Shape-checked because `tools` is JSON-authored: throwing here would skip the agent's
// own state below, leaving it with no schema rather than with no tool schemas.
const tools = Array.isArray(value.tools) ? value.tools : []
await Promise.all(
(value.tools ?? []).filter(isFlowModuleTool).map(async (tool) => {
tools.filter(isFlowModuleTool).map(async (tool) => {
modulesState[tool.id] = await loadFlowModuleState(agentToolToFlowModule(tool), workspace)
})
)
@@ -122,7 +125,7 @@ export async function publishLinkedAgentTools(
const gen = claimLinkedToolsFetch(scope, moduleId)
const tools = await resolveLinkedAgentTools(agentRef, workspace)
if (linkedToolFetchGen.get(genKey) === gen) {
setLinkedAgentTools(scope, moduleId, tools)
setLinkedAgentTools(scope, moduleId, tools, agentRef)
}
}
@@ -22,6 +22,19 @@ import { findNextAvailablePath } from '$lib/path'
import type { ExtendedOpenFlow } from './types'
import { emptySchema, type StateStore } from '$lib/utils'
import { loadStoredConfig } from '../aiProviderStorage'
import { copilotInfo } from '$lib/aiStore'
import { stepSettingDefaults } from './flowStepSettings'
import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte'
import {
agentToolToFlowModule,
createAiAgentTool,
createMcpTool,
createWebsearchTool,
newFlowModuleAgentTool,
SPECIAL_TOOL_KINDS,
type AgentTool,
type SpecialToolKind
} from './agentToolUtils'
export async function loadFlowModuleState(
flowModule: FlowModule,
@@ -190,7 +203,7 @@ export async function createAiAgent(
const providerValue = storedConfig ?? { kind: 'openai', resource: '', model: '' }
// A step linked to a saved agent reads its brain and tools from the resource, so it carries only
// the flow-local inputs: seeding `provider`/`output_type` would leave transforms it never reads.
// the flow-local inputs: seeding `provider` would leave a transform it never reads.
const aiAgentFlowModules: FlowModule = {
id,
value: {
@@ -201,8 +214,7 @@ export async function createAiAgent(
...(agentPath
? {}
: {
provider: { type: 'static', value: providerValue },
output_type: { type: 'static', value: 'text' }
provider: { type: 'static', value: providerValue }
}),
user_message: { type: 'static', value: undefined }
}
@@ -442,3 +454,157 @@ export async function insertNewFailureModule(
flowStateStore.val[module.id] = state
}
/** Create a new FlowModule without inserting it into any array. */
export async function createNewModule(
flowStore: StateStore<OpenFlow>,
flowStateStore: StateStore<FlowState>,
kind: InsertKind,
wsScript?: { path: string; summary: string; hash: string | undefined },
wsFlow?: { path: string; summary: string },
inlineScript?: InlineScript,
agentPath?: string,
// The acting workspace when the flow editor runs in an AI session; else the nav workspace.
workspace?: string,
disableAi?: boolean
): Promise<FlowModule> {
let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow')
let state = emptyFlowModuleState()
flowStateStore.val[module.id] = state
if (wsFlow) {
;[module, state] = await pickFlow(wsFlow.path, wsFlow.summary, module.id, workspace)
} else if (wsScript) {
;[module, state] = await pickScript(
wsScript.path,
wsScript.summary,
module.id,
wsScript.hash,
kind,
workspace
)
} else if (kind == 'forloop') {
;[module, state] = await createLoop(module.id, !disableAi && get(copilotInfo).enabled)
} else if (kind == 'whileloop') {
;[module, state] = await createWhileLoop(module.id)
} else if (kind == 'branchone') {
;[module, state] = await createBranches(module.id)
} else if (kind == 'branchall') {
;[module, state] = await createBranchAll(module.id)
} else if (kind == 'aiagent') {
;[module, state] = await createAiAgent(module.id, agentPath)
} else if (inlineScript) {
const { language, kind, subkind, summary } = inlineScript
;[module, state] = await createInlineScriptModule(language, kind, subkind, module.id, summary)
flowStateStore.val[module.id] = state
if (kind == 'trigger') {
module.summary = 'Trigger'
} else if (kind == 'approval') {
module.summary = 'Approval'
}
}
flowStateStore.val[module.id] = state
if (kind == 'approval') {
module.suspend = stepSettingDefaults('suspend')
} else if (kind == 'trigger') {
module.stop_after_if = stepSettingDefaults('early-stop', 'trigger')
} else if (kind == 'end') {
module.summary = 'Terminate flow'
module.stop_after_if = stepSettingDefaults('early-stop', 'end')
}
return module
}
/** Create a module and splice it into `modules`, as a step or as one of an agent's tools. */
export async function insertNewModuleAtIndex(
flowStore: StateStore<OpenFlow>,
flowStateStore: StateStore<FlowState>,
modules: FlowModule[] | AgentTool[],
index: number,
kind: InsertKind,
wsScript?: { path: string; summary: string; hash: string | undefined },
wsFlow?: { path: string; summary: string },
inlineScript?: InlineScript,
toolKind?: SpecialToolKind | 'flowmoduleTool',
// The acting workspace when the flow editor runs in an AI session; else the nav workspace.
workspace?: string,
disableAi?: boolean
): Promise<FlowModule[] | AgentTool[]> {
const module = await createNewModule(
flowStore,
flowStateStore,
kind,
wsScript,
wsFlow,
inlineScript,
undefined,
workspace,
disableAi
)
if (!modules) return [module]
if (toolKind === 'mcpTool') {
// Create MCP AgentTool
const mcpTool = createMcpTool(module.id)
;(modules as AgentTool[]).splice(index, 0, mcpTool)
return modules as AgentTool[]
} else if (toolKind === 'websearchTool') {
// Create Websearch AgentTool
const websearchTool = createWebsearchTool(module.id)
;(modules as AgentTool[]).splice(index, 0, websearchTool)
return modules as AgentTool[]
} else if (toolKind === 'aiAgentTool') {
// Create AI Agent tool (nested agent)
const aiAgentTool = createAiAgentTool(module.id)
flowStateStore.val[module.id] = await loadFlowModuleState(
agentToolToFlowModule(aiAgentTool),
workspace
)
;(modules as AgentTool[]).splice(index, 0, aiAgentTool)
return modules as AgentTool[]
} else if (toolKind === 'flowmoduleTool') {
const agentTool = newFlowModuleAgentTool(module)
;(modules as AgentTool[]).splice(index, 0, agentTool)
return modules as AgentTool[]
} else {
// Standard FlowModule insertion
modules.splice(index, 0, module)
return modules
}
}
/**
* Add one tool to an agent's `tools`, from the picker's detail, and return its id. Shared by the
* flow graph and the agent editor, which hold the same tools in different stores.
*/
export async function insertAgentTool(
flowStore: StateStore<OpenFlow>,
flowStateStore: StateStore<FlowState>,
agentValue: { tools?: AgentTool[] },
detail: { kind: string; script?: any; flow?: any; inlineScript?: any },
workspace?: string,
disableAi?: boolean
): Promise<string | undefined> {
// `tools` is optional, so a module authored without it starts undefined here and would
// silently swallow its first tool.
agentValue.tools ??= []
const tools = agentValue.tools
await insertNewModuleAtIndex(
flowStore,
flowStateStore,
tools,
tools.length,
detail.kind as InsertKind,
detail.script,
detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined,
detail.inlineScript,
(SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind)
? (detail.kind as SpecialToolKind)
: 'flowmoduleTool',
workspace,
disableAi
)
return tools[tools.length - 1]?.id
}
@@ -1,15 +0,0 @@
import type { OpenFlow } from '$lib/gen'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { StateStore } from '$lib/utils'
import { reanchorAgentEditsAcross } from './agentEditStore.svelte'
// Use at content-preserving refresh sites (structural edits, schema/failure/mock changes): a bare
// refreshStateStore clone would break the agent edit marker's array identity. Content-changing
// replacements (undo, YAML/diff/AI apply, session restores) must stay bare so stale edit state
// keeps invalidating.
export function refreshFlowStateStore(flowStore: StateStore<OpenFlow>) {
reanchorAgentEditsAcross(
() => flowStore.val.value?.modules,
() => refreshStateStore(flowStore)
)
}
@@ -14,6 +14,9 @@ export const forbiddenIds: string[] = [
'in',
'failure',
'preprocessor',
// The agent editor's synthetic root module. It shares one flow-state map with the agent's
// tools, so a tool of this id would take the root's slot and show the agent's own schema.
'__wm_agent_root',
'as',
'Input',
'Result',
@@ -6,6 +6,28 @@ import type { AgentTool } from './agentToolUtils'
// keeps flows shown at the same time (an editor and an embedded preview) from aliasing each other.
let byScope = $state<Record<string, Record<string, AgentTool[]>>>({})
// Which agent each entry resolved from, so a write to one agent can be reflected on every step that
// links it rather than only the one that triggered the write. Plain, not reactive: it is read
// imperatively when reconciling a deploy, never rendered.
const agentRefByEntry = new Map<string, string>()
/** A step may name its agent bare or as `$res:<path>`; both are the same agent, and the index has
* to answer for a lookup written either way. */
function normalizeAgentRef(agentRef: string): string {
return agentRef.replace(/^\$res:/, '').replace(/^res:\/\//, '')
}
function entryKey(scope: string, moduleId: string): string {
return `${scope}\u0000${moduleId}`
}
function forgetScopeRefs(scope: string) {
const prefix = `${scope}\u0000`
for (const key of agentRefByEntry.keys()) {
if (key.startsWith(prefix)) agentRefByEntry.delete(key)
}
}
// A long-lived tab would otherwise keep every flow it ever visited (raw tool script contents
// included). Past this cap the least recently used scope is evicted, skipping any a mounted view
// still holds.
@@ -61,6 +83,7 @@ function evictOverCap(protect?: string) {
}
scopeOrder = scopeOrder.filter((s) => s !== victim)
delete byScope[victim]
forgetScopeRefs(victim)
}
}
@@ -85,7 +108,16 @@ export function linkedToolsScope(
// track this to re-run when a link resolves after the initial render, e.g. right after linking.
let version = $state(0)
export function setLinkedAgentTools(scope: string, moduleId: string, tools: AgentTool[]) {
export function setLinkedAgentTools(
scope: string,
moduleId: string,
tools: AgentTool[],
/** The agent these tools came from. Taken here rather than recorded separately so the index and
* the tools cannot drift: every writer has the ref in hand, and one that forgot to file it left
* a step invisible to `linkedModulesForAgent`. */
agentRef: string
) {
agentRefByEntry.set(entryKey(scope, moduleId), normalizeAgentRef(agentRef))
// Publishers re-run and hand us a fresh-but-equal array each time; only mutate on a real change,
// else the version bump would retrigger the graph recompute in a loop.
if (deepEqual(byScope[scope]?.[moduleId], tools)) return
@@ -94,12 +126,29 @@ export function setLinkedAgentTools(scope: string, moduleId: string, tools: Agen
version++
}
/** Every module in this scope resolved from `agentRef`. A saved agent can be linked by more than
* one step, and all of them show tools that a write to it has just changed. */
export function linkedModulesForAgent(scope: string, agentRef: string): string[] {
const prefix = `${scope}\u0000`
const wanted = normalizeAgentRef(agentRef)
const out: string[] = []
for (const [key, ref] of agentRefByEntry) {
if (key.startsWith(prefix) && ref === wanted) out.push(key.slice(prefix.length))
}
return out
}
/** Move one scope's resolutions into another: used when a rename moves readers to a new scope,
* and to sweep republished data (keyed by the flow doc's path) into the live-edited scope. The
* source bucket carries the newer resolution in both cases, so it wins the merge. */
export function migrateLinkedAgentToolsScope(oldScope: string, newScope: string) {
if (oldScope === newScope || byScope[oldScope] === undefined) return
byScope[newScope] = { ...(byScope[newScope] ?? {}), ...(byScope[oldScope] ?? {}) }
for (const moduleId of Object.keys(byScope[oldScope] ?? {})) {
const ref = agentRefByEntry.get(entryKey(oldScope, moduleId))
if (ref !== undefined) agentRefByEntry.set(entryKey(newScope, moduleId), ref)
}
forgetScopeRefs(oldScope)
delete byScope[oldScope]
pendingMigration = newScope
// Mark the new key most-recently-used but don't evict here: the next publish or release enforces
@@ -109,6 +158,7 @@ export function migrateLinkedAgentToolsScope(oldScope: string, newScope: string)
}
export function clearLinkedAgentTools(scope: string, moduleId: string) {
agentRefByEntry.delete(entryKey(scope, moduleId))
if (byScope[scope]?.[moduleId] === undefined) return
const rest = { ...byScope[scope] }
delete rest[moduleId]
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
clearLinkedAgentTools,
getLinkedAgentTools,
linkedModulesForAgent,
linkedToolsScope,
migrateLinkedAgentToolsScope,
releaseLinkedToolsScope,
@@ -26,7 +27,7 @@ function freshScope(name = 'flow') {
function fillPastCap(exclude: string) {
for (let i = 0; i <= MAX_SCOPES; i++) {
const filler = linkedToolsScope('filler', `${exclude}-${seq}-${i}`)
setLinkedAgentTools(filler, 'm', [tool(`f${i}`)])
setLinkedAgentTools(filler, 'm', [tool(`f${i}`)], 'u/admin/a')
}
}
@@ -34,15 +35,15 @@ describe('linkedAgentToolsStore', () => {
it('keeps each scope module map separate', () => {
const a = freshScope()
const b = freshScope()
setLinkedAgentTools(a, 'step', [tool('x')])
setLinkedAgentTools(b, 'step', [tool('y')])
setLinkedAgentTools(a, 'step', [tool('x')], 'u/admin/a')
setLinkedAgentTools(b, 'step', [tool('y')], 'u/admin/a')
expect(getLinkedAgentTools(a, 'step').map((t) => t.id)).toEqual(['x'])
expect(getLinkedAgentTools(b, 'step').map((t) => t.id)).toEqual(['y'])
})
it('evicts an unretained scope once past the cap', () => {
const victim = freshScope()
setLinkedAgentTools(victim, 'step', [tool('x')])
setLinkedAgentTools(victim, 'step', [tool('x')], 'u/admin/a')
fillPastCap(victim)
expect(getLinkedAgentTools(victim, 'step')).toEqual([])
})
@@ -50,7 +51,7 @@ describe('linkedAgentToolsStore', () => {
// A run viewer holds one scope per mounted nested job; those must not evict what is on screen.
it('never evicts a retained scope', () => {
const held = freshScope()
setLinkedAgentTools(held, 'step', [tool('x')])
setLinkedAgentTools(held, 'step', [tool('x')], 'u/admin/a')
retainLinkedToolsScope(held)
fillPastCap(held)
expect(getLinkedAgentTools(held, 'step').map((t) => t.id)).toEqual(['x'])
@@ -63,7 +64,7 @@ describe('linkedAgentToolsStore', () => {
const held: string[] = []
for (let i = 0; i <= MAX_SCOPES; i++) {
const scope = linkedToolsScope('retained', `${seq}-${i}`)
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)])
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)], 'u/admin/a')
retainLinkedToolsScope(scope)
held.push(scope)
}
@@ -84,7 +85,7 @@ describe('linkedAgentToolsStore', () => {
it('carries tools across a rename without evicting the new scope', () => {
const from = freshScope('old')
const to = freshScope('new')
setLinkedAgentTools(from, 'step', [tool('x')])
setLinkedAgentTools(from, 'step', [tool('x')], 'u/admin/a')
retainLinkedToolsScope(from)
migrateLinkedAgentToolsScope(from, to)
expect(getLinkedAgentTools(to, 'step').map((t) => t.id)).toEqual(['x'])
@@ -98,13 +99,13 @@ describe('linkedAgentToolsStore', () => {
const held: string[] = []
for (let i = 0; i < MAX_SCOPES; i++) {
const scope = linkedToolsScope('rename-fill', `${seq}-${i}`)
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)])
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)], 'u/admin/a')
retainLinkedToolsScope(scope)
held.push(scope)
}
const from = freshScope('before')
const to = freshScope('after')
setLinkedAgentTools(from, 'step', [tool('x')])
setLinkedAgentTools(from, 'step', [tool('x')], 'u/admin/a')
retainLinkedToolsScope(from)
migrateLinkedAgentToolsScope(from, to)
@@ -121,10 +122,39 @@ describe('linkedAgentToolsStore', () => {
it('clears one module without disturbing its siblings', () => {
const scope = freshScope()
setLinkedAgentTools(scope, 'a', [tool('x')])
setLinkedAgentTools(scope, 'b', [tool('y')])
setLinkedAgentTools(scope, 'a', [tool('x')], 'u/admin/a')
setLinkedAgentTools(scope, 'b', [tool('y')], 'u/admin/a')
clearLinkedAgentTools(scope, 'a')
expect(getLinkedAgentTools(scope, 'a')).toEqual([])
expect(getLinkedAgentTools(scope, 'b').map((t) => t.id)).toEqual(['y'])
})
// A deploy has to refresh every step showing the agent it wrote, so this index is what makes the
// difference between one step's nodes updating and all of them.
describe('linkedModulesForAgent', () => {
it('finds every module linked to one agent, and no others', () => {
const scope = freshScope()
setLinkedAgentTools(scope, 'a', [tool('x')], 'u/admin/one')
setLinkedAgentTools(scope, 'b', [tool('y')], 'u/admin/one')
setLinkedAgentTools(scope, 'c', [tool('z')], 'u/admin/other')
expect(linkedModulesForAgent(scope, 'u/admin/one').sort()).toEqual(['a', 'b'])
expect(linkedModulesForAgent(scope, 'u/admin/other')).toEqual(['c'])
})
it('reads a $res-prefixed link and a bare path as the same agent', () => {
const scope = freshScope()
setLinkedAgentTools(scope, 'a', [tool('x')], '$res:u/admin/one')
expect(linkedModulesForAgent(scope, 'u/admin/one')).toEqual(['a'])
})
it('drops a module that was cleared, and stays scoped', () => {
const scope = freshScope()
const other = freshScope()
setLinkedAgentTools(scope, 'a', [tool('x')], 'u/admin/one')
setLinkedAgentTools(other, 'a', [tool('x')], 'u/admin/one')
clearLinkedAgentTools(scope, 'a')
expect(linkedModulesForAgent(scope, 'u/admin/one')).toEqual([])
expect(linkedModulesForAgent(other, 'u/admin/one')).toEqual(['a'])
})
})
})
@@ -57,7 +57,7 @@ describe('linked tools fetch guard', () => {
)
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
setLinkedAgentTools(scope, 'step', [tool('kept')])
setLinkedAgentTools(scope, 'step', [tool('kept')], 'u/admin/a')
invalidateLinkedToolsFetches(scope)
release?.({ value: { tools: [tool('stale')] } })
@@ -75,7 +75,7 @@ describe('linked tools fetch guard', () => {
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
claimLinkedToolsFetch(scope, 'step')
setLinkedAgentTools(scope, 'step', [tool('direct')])
setLinkedAgentTools(scope, 'step', [tool('direct')], 'u/admin/a')
release?.({ value: { tools: [tool('stale')] } })
await inFlight
@@ -1,5 +1,6 @@
<script lang="ts">
import type { FlowEditorContext } from '../types'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { FlowDiffManager } from '../flowDiffManager.svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { Bug, X } from 'lucide-svelte'
@@ -7,7 +8,6 @@
import { insertNewFailureModule } from '$lib/components/flows/flowStateUtils.svelte'
import type { RawScript, ScriptLang } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import DiffActionBar from './DiffActionBar.svelte'
import { getNodeColorClasses, aiActionToNodeState } from '$lib/components/graph'
@@ -58,7 +58,7 @@
}
selectionManager.selectId('failure')
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
}
function deleteFailureModule() {
@@ -1,22 +1,16 @@
<script lang="ts">
import type { FlowEditorContext } from '../types'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
import { createEventDispatcher, getContext, tick } from 'svelte'
import {
createInlineScriptModule,
createBranchAll,
createBranches,
createLoop,
createWhileLoop,
emptyModule,
pickScript,
pickFlow,
insertNewPreprocessorModule,
createAiAgent
createNewModule as createNewModuleIn,
insertNewModuleAtIndex as insertNewModuleAt,
insertAgentTool
} from '$lib/components/flows/flowStateUtils.svelte'
import type { FlowModule, Job, ScriptLang } from '$lib/gen'
import { emptyFlowModuleState } from '../utils.svelte'
import { stepSettingDefaults } from '../flowStepSettings'
import { dfs } from '../dfs'
import { nextId, copyId } from '../flowModuleNextId'
@@ -27,7 +21,6 @@
import { locateModules, groupByParent } from '../multiSelectUtils'
import { workspaceStore } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
import FlowTutorials from '$lib/components/FlowTutorials.svelte'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import { replaceId } from '../flowStore.svelte'
@@ -38,23 +31,12 @@
import { addBranch as addBranchOp, removeBranch as removeBranchOp } from '../branchOps'
import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte'
import { MoveManager } from '$lib/components/graph/moveManager.svelte'
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
import type { GraphModuleState } from '$lib/components/graph'
import FlowStickyNode from './FlowStickyNode.svelte'
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
import type { StateStore } from '$lib/utils'
import {
type AgentTool,
type SpecialToolKind,
newFlowModuleAgentTool,
createMcpTool,
createWebsearchTool,
createAiAgentTool,
SPECIAL_TOOL_KINDS,
agentToolToFlowModule
} from '../agentToolUtils'
import { loadFlowModuleState } from '../flowStateUtils.svelte'
import { type AgentTool, type SpecialToolKind } from '../agentToolUtils'
import type { DeletePlan } from '../flowDeleteUtils'
import { executeDeletePlan, prepareDeleteRequest } from '../flowDeleteController'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
@@ -161,62 +143,56 @@
})
/** Create a new FlowModule without inserting it into any array */
async function createNewModule(
function createNewModule(
kind: InsertKind,
wsScript?: { path: string; summary: string; hash: string | undefined },
wsFlow?: { path: string; summary: string },
inlineScript?: InlineScript,
agentPath?: string
): Promise<FlowModule> {
let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow')
let state = emptyFlowModuleState()
flowStateStore.val[module.id] = state
if (wsFlow) {
;[module, state] = await pickFlow(wsFlow.path, wsFlow.summary, module.id, opWs)
} else if (wsScript) {
;[module, state] = await pickScript(
wsScript.path,
wsScript.summary,
module.id,
wsScript.hash,
kind,
opWs
)
} else if (kind == 'forloop') {
;[module, state] = await createLoop(module.id, !disableAi && $copilotInfo.enabled)
} else if (kind == 'whileloop') {
;[module, state] = await createWhileLoop(module.id)
} else if (kind == 'branchone') {
;[module, state] = await createBranches(module.id)
} else if (kind == 'branchall') {
;[module, state] = await createBranchAll(module.id)
} else if (kind == 'aiagent') {
;[module, state] = await createAiAgent(module.id, agentPath)
} else if (inlineScript) {
const { language, kind, subkind, summary } = inlineScript
;[module, state] = await createInlineScriptModule(language, kind, subkind, module.id, summary)
flowStateStore.val[module.id] = state
if (kind == 'trigger') {
module.summary = 'Trigger'
} else if (kind == 'approval') {
module.summary = 'Approval'
}
}
flowStateStore.val[module.id] = state
if (kind == 'approval') {
module.suspend = stepSettingDefaults('suspend')
} else if (kind == 'trigger') {
module.stop_after_if = stepSettingDefaults('early-stop', 'trigger')
} else if (kind == 'end') {
module.summary = 'Terminate flow'
module.stop_after_if = stepSettingDefaults('early-stop', 'end')
}
return module
return createNewModuleIn(
flowStore,
flowStateStore,
kind,
wsScript,
wsFlow,
inlineScript,
agentPath,
opWs,
disableAi
)
}
export async function insertNewModuleAtIndex(
/**
* Add a tool to an agent, from the graph's own `+ Tool` or from a surface that has no graph
* node to click — the agent step's Tools section. Kept here because it needs the map's history,
* id allocation and `flowStateStore` seeding.
*/
export async function addToolToAgent(
agentId: string,
detail: { kind: string; script?: any; flow?: any; inlineScript?: any }
) {
push(history, flowStore.val)
const agentMod = findModuleInFlow(flowStore.val.value, agentId)
const agentValue = agentMod?.value as { tools?: AgentTool[] } | undefined
if (agentValue) {
const id = await insertAgentTool(
flowStore,
flowStateStore,
agentValue,
detail,
opWs,
disableAi
)
// Reveal the new tool's config right away — in modal (unanchored) panel mode its editor
// is otherwise hidden behind the graph.
if (id) selectionManager.selectId(id, { openPanel: true })
}
refreshStateStore(flowStore)
dispatch('change')
}
export function insertNewModuleAtIndex(
modules: FlowModule[] | AgentTool[],
index: number,
kind: InsertKind,
@@ -226,38 +202,19 @@
toolKind?: SpecialToolKind | 'flowmoduleTool'
): Promise<FlowModule[] | AgentTool[]> {
push(history, flowStore.val)
const module = await createNewModule(kind, wsScript, wsFlow, inlineScript)
if (!modules) return [module]
if (toolKind === 'mcpTool') {
// Create MCP AgentTool
const mcpTool = createMcpTool(module.id)
;(modules as AgentTool[]).splice(index, 0, mcpTool)
return modules as AgentTool[]
} else if (toolKind === 'websearchTool') {
// Create Websearch AgentTool
const websearchTool = createWebsearchTool(module.id)
;(modules as AgentTool[]).splice(index, 0, websearchTool)
return modules as AgentTool[]
} else if (toolKind === 'aiAgentTool') {
// Create AI Agent tool (nested agent)
const aiAgentTool = createAiAgentTool(module.id)
flowStateStore.val[module.id] = await loadFlowModuleState(
agentToolToFlowModule(aiAgentTool),
opWs
)
;(modules as AgentTool[]).splice(index, 0, aiAgentTool)
return modules as AgentTool[]
} else if (toolKind === 'flowmoduleTool') {
const agentTool = newFlowModuleAgentTool(module)
;(modules as AgentTool[]).splice(index, 0, agentTool)
return modules as AgentTool[]
} else {
// Standard FlowModule insertion
modules.splice(index, 0, module)
return modules
}
return insertNewModuleAt(
flowStore,
flowStateStore,
modules,
index,
kind,
wsScript,
wsFlow,
inlineScript,
toolKind,
opWs,
disableAi
)
}
let sidebarMode: 'list' | 'graph' = 'graph'
@@ -409,7 +366,7 @@
parentArr.splice(lastIndex + 1, 0, ...clones)
}
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
selectionManager.selectByIds(allCloneIds)
}
@@ -669,7 +626,7 @@
selectionManager.selectId(movingId)
}
moveManager.clearMoving()
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
dispatch('change')
}
@@ -705,48 +662,19 @@
instructions: detail.inlineScript?.instructions
})
}
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
dispatch('change')
return
}
// Agent tool inserts operate on the FlowModule's tools array directly
if (detail.agentId) {
await addToolToAgent(detail.agentId, detail)
return
}
push(history, flowStore.val)
const isAgentInsert = !!detail.agentId
const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = isAgentInsert
? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind)
? (detail.kind as SpecialToolKind)
: 'flowmoduleTool'
: undefined
// Agent tool inserts operate on the FlowModule's tools array directly
if (isAgentInsert) {
const agentMod = findModuleInFlow(flowStore.val.value, detail.agentId!)
const agentValue = agentMod?.value as { tools?: AgentTool[] } | undefined
if (agentValue) {
// `tools` is optional, so a module authored without it starts undefined here and
// would silently swallow its first tool.
agentValue.tools ??= []
const tools = agentValue.tools
await insertNewModuleAtIndex(
tools,
tools.length,
detail.kind as InsertKind,
detail.script,
detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined,
detail.inlineScript,
toolKind
)
const id = tools[tools.length - 1].id
// Reveal the new tool's config right away — in modal (unanchored)
// panel mode its editor is otherwise hidden behind the graph.
selectionManager.selectId(id, { openPanel: true })
}
refreshFlowStateStore(flowStore)
dispatch('change')
return
}
// Regular module insert: create the module, then insert a leaf node via tree mutation
const module = await createNewModule(
detail.kind as InsertKind,
@@ -823,13 +751,13 @@
if (['branchone', 'branchall'].includes(detail.kind)) {
await addBranch(module.id)
}
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
dispatch('change')
}}
onNewBranch={async (id) => {
if (id) {
await addBranch(id)
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
}
}}
onSelect={(id) => {
@@ -883,13 +811,13 @@
}
flowStateStore.val[newId] = flowStateStore.val[id]
delete flowStateStore.val[id]
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
selectionManager.selectId(newId)
}}
onDeleteBranch={async ({ id, index }) => {
if (id) {
await removeBranch(id, index)
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
selectionManager.selectId(id)
}
}}
@@ -928,7 +856,7 @@
})
targetModules.splice(targetIndex + 1, 0, clone)
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
selectionManager.selectId(clone.id, { openPanel: true })
}}
onUpdateMock={(detail) => {
@@ -937,7 +865,7 @@
throw new Error(`Node ${detail.id} not found`)
}
module.mock = $state.snapshot(detail.mock)
refreshFlowStateStore(flowStore)
refreshStateStore(flowStore)
}}
{onTestFlow}
{isRunning}
@@ -31,6 +31,7 @@
import { base } from '$lib/base'
import { fade } from 'svelte/transition'
import type { FlowEditorContext, OutputViewerJob } from '../types'
import { NEVER_TESTED_THIS_FAR } from '../models'
import { logFeatureUsage } from '$lib/utils/featureUsage'
interface Props {
@@ -192,7 +193,7 @@
} else if (selectedJob && 'result' in selectedJob) {
// Pin the job
let mockValue: any = structuredClone($state.snapshot(selectedJob.result))
if (selectedJob.result === 'never tested this far') {
if (selectedJob.result === NEVER_TESTED_THIS_FAR) {
mockValue = { example: 'value' }
}
const newMock = {
@@ -222,21 +223,23 @@
if (testJob && (testJob.result_stream || testJob.type === 'QueuedJob' || !moduleId)) {
return testJob
}
if (
!flowStateStore ||
!moduleId ||
flowStateStore.val[moduleId]?.previewResult === 'never tested this far'
) {
// A module with no state yet has no result either. Building the job below from a missing
// entry mints a completed-and-failed job with no id, which shows as a red badge on a step
// that never ran; and `selectJob` only ever moves off a job it has selected, so it stays
// once the state arrives.
const moduleState = moduleId ? flowStateStore?.val[moduleId] : undefined
if (!moduleState || moduleState.previewResult === NEVER_TESTED_THIS_FAR) {
return
}
const { previewJobId, previewResult, previewSuccess, previewLogs } = moduleState
// Use flowStateStore as source of truth — it's updated by both individual step tests
// (ModuleTest.jobDone) and flow tests (FlowStatusViewerInner.onJobsLoadedInner)
return {
id: flowStateStore.val[moduleId]?.previewJobId ?? '',
result: flowStateStore.val[moduleId]?.previewResult,
id: previewJobId ?? '',
result: previewResult,
type: 'CompletedJob' as const,
success: flowStateStore.val[moduleId]?.previewSuccess ?? undefined,
logs: flowStateStore.val[moduleId]?.previewLogs
success: previewSuccess ?? undefined,
logs: previewLogs
} as Job & { result_stream?: string } & { preview?: boolean }
}
@@ -107,6 +107,10 @@ export type FlowEditorContext = {
// $workspaceStore inside a fork-scoped session; worker-tag pickers read it so
// their tag list and availability match the deploy target. Getter for reactivity.
opWorkspace?: () => string | undefined
// The agent whose editor hosts this flow, when one does. An agent editor hosts its flow under
// that agent's own path, so `pathStore` alone cannot say whether a step belongs to a flow or to
// an agent being edited — a flow and a resource may share a path string.
agentEditorHost?: () => string | undefined
}
export type FlowGraphAssetContext = StateStore<{
@@ -0,0 +1,41 @@
import { CancelError, WorkspaceService } from '$lib/gen'
import { resource } from 'runed'
/**
* Whether the workspace has S3 storage configured, for the fields that warn without it. Call during
* component initialisation and read `.current` where the answer is used.
*/
export function useS3StorageConfigured(ws: () => string | undefined): {
readonly current: boolean
} {
const settings = resource(ws, async (ws, _previousWs, { onCleanup }) => {
if (!ws) return undefined
const req = WorkspaceService.getPublicSettings({ workspace: ws })
// `resource` keeps whatever lands last: cancel a superseded request so a slow
// reply for a workspace we have left cannot overwrite the current one.
onCleanup(() => req.cancel())
try {
return { ws, settings: await req }
} catch (err) {
if (!(err instanceof CancelError)) {
console.error('Failed to fetch workspace settings:', err)
}
return undefined
}
})
// Assume configured until this workspace's own answer lands: the warning must not
// linger from the previous workspace, nor appear merely because the fetch failed.
const configured = $derived.by(() => {
const loaded = settings.current
return loaded && loaded.ws === ws()
? loaded.settings.large_file_storage?.s3_resource_path !== undefined
: true
})
return {
get current() {
return configured
}
}
}
@@ -84,6 +84,7 @@
onEditInForkClick
} from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { agentStreamingEnabled } from '$lib/components/flows/agentFormFields'
let flow: Flow | undefined = $state()
let can_write = $state(false)
@@ -525,11 +526,8 @@
let shouldUseStreaming = $derived.by(() => {
const modules = flow?.value?.modules
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
return (
lastModule?.value?.type === 'aiagent' &&
lastModule?.value?.input_transforms?.streaming?.type === 'static' &&
lastModule?.value?.input_transforms?.streaming?.value === true
)
if (lastModule?.value?.type !== 'aiagent') return false
return agentStreamingEnabled(lastModule.value)
})
</script>
@@ -68,7 +68,6 @@
Plus,
RotateCw,
Save,
FlaskConical,
SearchX,
Shield,
Trash,
@@ -78,12 +77,19 @@
import autosize from '$lib/autosize'
import EditableSchemaWrapper from '$lib/components/schema/EditableSchemaWrapper.svelte'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import {
agentEditorTarget,
closeAgentEditor,
openAgentEditor
} from '$lib/components/flows/agentEditorStore.svelte'
import { copilotInfo } from '$lib/aiStore'
import { setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession'
import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import ExploreAssetButton, {
assetCanBeExplored
} from '../../../../lib/components/ExploreAssetButton.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import AgentEvalModal from '$lib/components/aiEvals/AgentEvalModal.svelte'
type ResourceW = ListableResource & { canWrite: boolean; marked?: string }
type ResourceTypeW = ResourceType & { canWrite: boolean }
@@ -129,14 +135,33 @@
isFileset: false
})
let resourceEditor: ResourceEditorDrawer | undefined = $state(undefined)
/** An `ai_agent` gets the agent editor rather than the generic resource form, which would
* render its configuration as raw JSON. Both write the same resource draft, so the choice is
* presentational and either can open a path the other left a draft at. */
function openResourceEditor(path: string, resourceType: string | undefined) {
if (resourceType === 'ai_agent') {
// The generic editor anchors itself from `initEdit`; this one has to, or the URL, a
// refresh, and the AI session's idea of where you are all miss the open agent. Claim the
// hash first so the deep-link effect does not treat our own write as a new navigation.
handledHash = `#/resource/${path}`
// One row at a time: the hash can retarget from a resource to an agent, and the two
// editors are separate overlays that would otherwise stack, the older one surfacing again
// when the newer is closed.
resourceEditor?.close?.({ keepAnchor: true })
openAgentEditor({ path })
setPageDrawerAnchor(RESOURCES_PATH, path)
} else {
closeAgentEditor()
resourceEditor?.initEdit?.(path)
}
}
let shareModal: ShareModal | undefined = $state(undefined)
let appConnect: AppConnect | undefined = $state(undefined)
let supabaseConnect: SupabaseConnect | undefined = $state(undefined)
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
let deleteIsLinked = $state(false)
let deletePath = $state('')
let evalsOpen = $state(false)
let evalsAgentPath = $state<string | undefined>(undefined)
let loading = $state({
resources: true,
types: true
@@ -646,10 +671,40 @@
return
}
if (hash === handledHash || !resourceEditor) return
// The type decides which editor opens, so wait for the list that carries it.
if (!resources) return
handledHash = hash
resourceEditor.initEdit(hash.slice(11))
const path = hash.slice(11)
void openResourceFromHash(path)
})
/** The listing is narrowed by the active filters, so a deep-linked resource may not be in it.
* Treating that absence as "unknown type" would open the generic form, which materializes a
* default into every field the value omits and so writes a draft just by rendering. Ask the
* server instead. */
async function openResourceFromHash(path: string) {
let resourceType = resources?.find((r) => r.path === path)?.resource_type
if (resourceType === undefined) {
// The hash can move on while this is in flight, and two lookups can land out of order.
// Whichever resolves last must not open an editor the URL has already left.
const openingFor = handledHash
try {
resourceType = (await ResourceService.getResource({ workspace: $workspaceStore!, path }))
.resource_type
} catch (err) {
if (handledHash !== openingFor) return
// Opening the generic form on an unknown type is the very thing this avoids, so a
// failed lookup opens nothing at all. The hash stays claimed: the effect above reads
// it, so releasing it here would re-enter this lookup and toast on a loop. Clicking
// the row is the way to try again.
sendUserToast(`Could not open ${path}: ${err}`, true)
return
}
if (handledHash !== openingFor) return
}
openResourceEditor(path, resourceType)
}
let showTable = $derived(
tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'
)
@@ -1106,7 +1161,7 @@
href="#/resource/{path}"
onclick={() => {
handledHash = `#/resource/${path}`
resourceEditor?.initEdit?.(path)
openResourceEditor(path, resource_type)
}}
>{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''}</a
>
@@ -1266,18 +1321,6 @@
<Dropdown
class="w-fit"
items={[
...(resource_type === 'ai_agent' && !draft_only
? [
{
displayName: 'Evals',
icon: FlaskConical,
action: () => {
evalsAgentPath = path
evalsOpen = true
}
}
]
: []),
{
displayName: 'Permissions',
icon: Shield,
@@ -1290,9 +1333,31 @@
icon: Pen,
disabled: !canWrite || !showCreateButtons,
action: () => {
resourceEditor?.initEdit?.(path)
openResourceEditor(path, resource_type)
}
},
// The agent form covers an agent's configuration, not everything a
// resource carries: the workspace-specific toggle in particular is
// only in the generic editor. JSON rather than that editor's form,
// which would render the configuration field by field and write a
// default into every one the agent leaves unset. Both write the same
// draft row, so this is a second view of the same edits.
...(resource_type === 'ai_agent'
? [
{
displayName: 'Edit as JSON',
icon: Braces,
disabled: !canWrite || !showCreateButtons,
action: () => {
// The drawer anchors itself in the hash, which the deep-link
// effect would then read and route back to the agent editor.
// Claim it first, as the row's own link does.
handledHash = `#/resource/${path}`
resourceEditor?.initEdit?.(path, { json: true })
}
}
]
: []),
...(!ws_specific && isDeployable('resource', path, deployUiSettings)
? [
{
@@ -1478,7 +1543,6 @@
<SupabaseConnect bind:this={supabaseConnect} on:refresh={loadResources} />
<AppConnect bind:this={appConnect} on:refresh={loadResources} />
<AgentEvalModal agentPath={evalsAgentPath} bind:open={evalsOpen} />
<ResourceEditorDrawer
bind:this={resourceEditor}
@@ -1492,3 +1556,13 @@
loadResources()
}}
/>
<!-- Same capabilities as from a flow step: the editor is the same one, so which surface opened it
must not decide whether its tools can be written with the copilot. -->
<!-- Imported only once an agent is opened: the editor pulls in the flow editor, which is most of
this route's JavaScript and none of what the resources table needs. -->
{#if agentEditorTarget()}
{#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }}
<AgentEditorModal enableAi={$copilotInfo.enabled} owns={(t) => t.host === undefined} />
{/await}
{/if}
@@ -16,6 +16,7 @@
import type { FlowState } from '$lib/components/flows/flowState'
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
import FlowEditorPanel from '$lib/components/flows/content/FlowEditorPanel.svelte'
import AgentEditorModal from '$lib/components/flows/content/AgentEditorModal.svelte'
import { deepEqual } from 'fast-equals'
import { findModuleInFlow } from '$lib/components/flows/flowDiff'
import { page } from '$app/state'
@@ -343,5 +344,9 @@
</div>
{/if}
</div>
<!-- Every surface that can select an agent step needs one: the card's Edit only sets a
module-global target, and without a mount here it would do nothing. `disableAi` matches
the map and panel above. -->
<AgentEditorModal owns={() => true} />
</div>
</main>