From d232d57f0d2e8f7120d00d9d7e04c22b158667fa Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 4 Sep 2026 07:55:20 -0400 Subject: [PATCH 1/5] offer known Google scopes as checkboxes in the oauth connect dialog (#10945) * feat: offer known Google scopes as checkboxes in the oauth connect dialog Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Kv72vdCDggCZEjJSmNCwnX * fix: keep custom oauth scope rows apart from checked options while typing Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Kv72vdCDggCZEjJSmNCwnX * fix: drop the rust scope_options field and render checkboxes from the ticked set Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Kv72vdCDggCZEjJSmNCwnX * fix: keep ticked oauth scope options independent of free-text rows Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Kv72vdCDggCZEjJSmNCwnX * fix: toggle oauth scope checkboxes from component state, not the reverted input Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Kv72vdCDggCZEjJSmNCwnX * fix: keep the legacy gforms default scope so pre-migration accounts still refresh Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Kv72vdCDggCZEjJSmNCwnX --------- Co-authored-by: Claude Fable 5.1 --- backend/oauth_connect.json | 41 +++++++ backend/windmill-oauth/src/lib.rs | 2 + .../src/lib/components/AppConnectInner.svelte | 2 +- .../src/lib/components/OauthScopes.svelte | 111 ++++++++++++++---- 4 files changed, 131 insertions(+), 25 deletions(-) diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index cb874eb2c7..3f8c265a4a 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -29,6 +29,10 @@ "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/spreadsheets"], + "scope_options": [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/spreadsheets.readonly" + ], "extra_params": { "access_type": "offline", "prompt": "consent" @@ -38,6 +42,11 @@ "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/drive"], + "scope_options": [ + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive" + ], "extra_params": { "access_type": "offline", "prompt": "consent" @@ -47,6 +56,13 @@ "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/gmail.send"], + "scope_options": [ + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.labels" + ], "extra_params": { "access_type": "offline", "prompt": "consent" @@ -56,6 +72,12 @@ "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/calendar.events"], + "scope_options": [ + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar" + ], "extra_params": { "access_type": "offline", "prompt": "consent" @@ -65,6 +87,12 @@ "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/forms"], + "scope_options": [ + "https://www.googleapis.com/auth/forms", + "https://www.googleapis.com/auth/forms.body", + "https://www.googleapis.com/auth/forms.body.readonly", + "https://www.googleapis.com/auth/forms.responses.readonly" + ], "extra_params": { "access_type": "offline", "prompt": "consent" @@ -74,6 +102,10 @@ "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/cloud-platform"], + "scope_options": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/cloud-platform.read-only" + ], "extra_params": { "access_type": "offline", "prompt": "consent" @@ -88,6 +120,15 @@ "https://www.googleapis.com/auth/admin.directory.user.security", "https://www.googleapis.com/auth/admin.directory.orgunit" ], + "scope_options": [ + "https://www.googleapis.com/auth/admin.directory.user", + "https://www.googleapis.com/auth/admin.directory.user.readonly", + "https://www.googleapis.com/auth/admin.directory.group", + "https://www.googleapis.com/auth/admin.directory.group.readonly", + "https://www.googleapis.com/auth/admin.directory.orgunit", + "https://www.googleapis.com/auth/admin.directory.orgunit.readonly", + "https://www.googleapis.com/auth/admin.directory.user.security" + ], "extra_params": { "access_type": "offline", "prompt": "consent" diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 2650b68b4d..32fde93b81 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -88,6 +88,8 @@ pub struct OAuthConfig { #[serde(default = "empty_string")] pub token_url: String, pub userinfo_url: Option, + /// The registry JSON may also carry `scope_options`, a frontend-only pick + /// list for the connect dialog; it is deliberately not modelled here. pub scopes: Option>, /// Default scopes for the client-credentials (2-legged) flow. These differ /// from the authorization-code `scopes` for most providers (member/consent diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 3d73cea72f..320b3e36dd 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -1476,7 +1476,7 @@ > {#if editScopes} - + {:else}
{#each scopes as scope} diff --git a/frontend/src/lib/components/OauthScopes.svelte b/frontend/src/lib/components/OauthScopes.svelte index 4ec17c6e2a..b9018bfe3b 100644 --- a/frontend/src/lib/components/OauthScopes.svelte +++ b/frontend/src/lib/components/OauthScopes.svelte @@ -1,51 +1,114 @@ -{#if scopes && Array.isArray(scopes)} - {#each scopes as v, i} -
- -
- {/each} +{#if options.length > 0} +
+ {#each options as option (option)} + + {/each} +
+ Custom scopes {/if} +{#each custom as v, i (i)} +
+ setRow(i, e.currentTarget.value) }} + /> +
+{/each} +
- - ({(scopes ?? []).length} item{(scopes ?? []).length > 1 ? 's' : ''}) - + {#if custom.length > 0} + + ({custom.length} item{custom.length > 1 ? 's' : ''}) + + {/if}
From 2257b05b2857c7ae2b5ae0b4f9004e2d4e757925 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:58:26 +0200 Subject: [PATCH 2/5] feat: make S3 permission rules reorderable by drag and drop (#10958) Claude-Session: https://claude.ai/code/session_01DkKR3V3rWDyh1tDZCxmGLT Co-authored-by: Claude Opus 5 (1M context) --- .../S3PermissionRulesEditor.svelte | 107 ++++++++++++++++++ .../workspaceSettings/StorageSettings.svelte | 39 +------ 2 files changed, 109 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/components/workspaceSettings/S3PermissionRulesEditor.svelte diff --git a/frontend/src/lib/components/workspaceSettings/S3PermissionRulesEditor.svelte b/frontend/src/lib/components/workspaceSettings/S3PermissionRulesEditor.svelte new file mode 100644 index 0000000000..d865756ff8 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/S3PermissionRulesEditor.svelte @@ -0,0 +1,107 @@ + + + + The first rule whose pattern matches the path decides what is allowed — drag rules to reorder + them. A path matched by no rule is denied. +

+ Standard Unix-style glob syntax is supported. The following will be interpolated: +
    +
  • {'{username}'} : Nickname of the user doing the request
  • +
  • {'{group}'} : Any group that the user belongs to
  • +
  • {'{folder_read}'} : Any folder that the user has read access to
  • +
  • {'{folder_write}'} : Any folder that the user has write access to
  • +
+
+ Note that changes may take up to 1 minute to propagate due to cache invalidation +
+ +
+
(items = e.detail.items)} + onfinalize={(e) => (items = e.detail.items)} + > + {#each items as item, idx (item.id)} + {@const shadowed = catchAllIdx !== undefined && idx > catchAllIdx} + +
+
+ + Rule {idx + 1} +
+ + + (items = items.filter((_, i) => i !== idx))} /> +
+ {/each} +
+
+{#if shadowWarning} + +{/if} + diff --git a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte index a2ccf0b958..025ef23c38 100644 --- a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte @@ -19,9 +19,8 @@ import S3FilePicker from '../S3FilePicker.svelte' import Portal from '../Portal.svelte' import Popover from '../meltComponents/Popover.svelte' - import ClearableInput from '../common/clearableInput/ClearableInput.svelte' - import MultiSelect from '../select/MultiSelect.svelte' import CloseButton from '../common/CloseButton.svelte' + import S3PermissionRulesEditor from './S3PermissionRulesEditor.svelte' import TextInput from '../text_input/TextInput.svelte' import Select from '../select/Select.svelte' import DataTable from '../table/DataTable.svelte' @@ -529,7 +528,7 @@ disabled={!storage.advancedPermissions && !$enterpriseLicense} /> {#if storage.advancedPermissions} - {@render advancedPermissionsEditor(storage.advancedPermissions)} + {/if} {#if !storage.advancedPermissions} {#if storage.resourceType == 's3'} @@ -585,37 +584,3 @@ {/if} {/if} - -{#snippet advancedPermissionsEditor(rules: S3ResourceSettingsItem['advancedPermissions'])} - - The following will be interpolated : -
    -
  • {'{username}'} : Nickname of the user doing the request
  • -
  • {'{group}'} : Any group that the user belongs to
  • -
  • {'{folder_read}'} : Any folder that the user has read access to
  • -
  • {'{folder_write}'} : Any folder that the user has write access to
  • -
-
- Note that changes may take up to 1 minute to propagate due to cache invalidation -
- -
- {#each rules ?? [] as item, idx} -
- - - rules?.splice(idx, 1)} /> -
- {/each} -
- -{/snippet} From 64b679879936e2ddf4dbc2f90edbd56e3893bd83 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 4 Sep 2026 12:43:42 +0000 Subject: [PATCH 3/5] fix: name the extension to load when duckdb autoload hits the fence (#10972) * fix: tell duckdb scripts which extension to name when autoload hits the fence Co-Authored-By: Claude Opus 5 * chore: update ee-repo-ref to b9aeffa83f0e601f123c7eab536b235719786da1 This commit updates the EE repository reference after PR #778 was merged in windmill-ee-private. Previous ee-repo-ref: fd196f99e22205c69946870997dadd921847cc97 New ee-repo-ref: b9aeffa83f0e601f123c7eab536b235719786da1 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 9e7a10edf0..dc5fe7ce82 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -d6297e6844dc2aab4745fce328e32ccab508969f +b9aeffa83f0e601f123c7eab536b235719786da1 From f037c73d104fffe7bb2640a5b1f2a92154c85e06 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 4 Sep 2026 16:32:17 +0200 Subject: [PATCH 4/5] 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 * feat: edit a saved AI agent through its own resource draft Co-Authored-By: Claude Opus 5 * refactor: drop the agent fork-for-edit session now that edits live in a draft Co-Authored-By: Claude Opus 5 * feat: edit ai_agent resources from the resources page with the agent editor Co-Authored-By: Claude Opus 5 * fix: send a standalone agent's brain from the module when testing a step Co-Authored-By: Claude Opus 5 * fix: keep the agent draft faithful to the resource it deploys to Co-Authored-By: Claude Opus 5 * chore: add the sqlx cache entry for the eval subject rename Co-Authored-By: Claude Opus 5 * refactor: share the module insert between the graph and the agent editor Co-Authored-By: Claude Opus 5 * feat(frontend): open evals inside the agent editor, actions in its header Co-Authored-By: Claude Opus 5 * feat(frontend): add tools from the agent editor and lighten its test pane Co-Authored-By: Claude Opus 5 * fix(frontend): open an ai_agent deep link in the agent editor Co-Authored-By: Claude Opus 5 * fix(frontend): drop the failed result badge on a step that never ran Co-Authored-By: Claude Opus 5 * feat(frontend): head the agent editor's levels with a back control Co-Authored-By: Claude Opus 5 * feat(frontend): drop connect and fill inputs from the agent editor Co-Authored-By: Claude Opus 5 * feat(frontend): lighten the agent editor's run panel Co-Authored-By: Claude Opus 5 * fix(frontend): stop a nested agent tool's config reading as AI-filled Co-Authored-By: Claude Opus 5 * feat(frontend): offer only AI or static on an agent tool's inputs Co-Authored-By: Claude Opus 5 * feat(frontend): give a saved agent's tool editor a static-only surface Co-Authored-By: Claude Opus 5 * feat(frontend): open an agent tool in a drawer beside the agent Co-Authored-By: Claude Opus 5 * feat(frontend): hide unset agent config in the run form Co-Authored-By: Claude Opus 5 * refactor(frontend): share the input forms' pickers and s3 lookup Co-Authored-By: Claude Opus 5 * chore(frontend): drop a dead agent-editor export and fix two stale comments Co-Authored-By: Claude Opus 5 * fix(frontend): reach an ai_agent's resource-level settings and copilot Co-Authored-By: Claude Opus 5 * fix(frontend): open an ai_agent's resource view as JSON, not the generic form Co-Authored-By: Claude Opus 5 * fix(frontend): address review findings on the agent editor's draft and streaming Co-Authored-By: Claude Opus 5 * fix(frontend): close the agent editor on a version restore, as the resource editor does Co-Authored-By: Claude Opus 5 * fix(frontend): stop the provider picker auto-writing a kind, and clear review nits Co-Authored-By: Claude Opus 5 * refactor(frontend): drop the fork-for-edit leftovers from the agent card Co-Authored-By: Claude Opus 5 * fix(frontend): mount the agent editor in the dev flow editor and guard the deep-link race Co-Authored-By: Claude Opus 5 * fix(frontend): deploy the agent config that was submitted, and refuse one no run could use Co-Authored-By: Claude Opus 5 * refactor(frontend): build the agent editor's rows from the design-system button Co-Authored-By: Claude Opus 5 * fix(frontend): keep a draft-only agent's draft, and let a blank MCP summary deploy Co-Authored-By: Claude Opus 5 * fix(frontend): guard read-only agents, incomplete MCP tools and duplicate editor mounts Co-Authored-By: Claude Opus 5 * 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 * fix(frontend): clear text-only agent fields on image output, reserve the root id Co-Authored-By: Claude Opus 5 * fix(frontend): keep the agent editor usable for a non-list tools value Co-Authored-By: Claude Opus 5 * fix(frontend): stop the parked eval run list from taking arrow keys Co-Authored-By: Claude Opus 5 * fix(frontend): report a non-list tools value on deploy instead of throwing Co-Authored-By: Claude Opus 5 * fix(frontend): keep temperature editable for image output Co-Authored-By: Claude Opus 5 * fix(frontend): skip non-object tool entries when rendering an agent Co-Authored-By: Claude Opus 5 * fix(frontend): guard tool entry reads instead of copying the tool array Co-Authored-By: Claude Opus 5 * fix(frontend): key tool rows by position so duplicate ids render Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- backend/windmill-worker/src/ai_executor.rs | 20 +- frontend/src/lib/common.ts | 2 + .../lib/components/AIProviderPicker.svelte | 211 ++++---- frontend/src/lib/components/ArgInput.svelte | 2 +- frontend/src/lib/components/Dev.svelte | 4 + .../lib/components/FlowPreviewContent.svelte | 8 +- .../lib/components/InputTransformForm.svelte | 115 ++++- .../components/InputTransformPickers.svelte | 68 +++ .../InputTransformSchemaForm.svelte | 90 +--- .../lib/components/ModulePreviewForm.svelte | 37 +- frontend/src/lib/components/ModuleTest.svelte | 44 +- .../src/lib/components/ResourceEditor.svelte | 7 +- .../components/ResourceEditorDrawer.svelte | 36 +- .../src/lib/components/ResourcePicker.svelte | 15 +- .../src/lib/components/TemplateEditor.svelte | 42 +- .../components/aiEvals/AgentEvalModal.svelte | 64 --- .../lib/components/aiEvals/EvalsPane.svelte | 37 +- .../lib/components/common/modal/Modal.svelte | 174 +++---- .../components/common/overlayHost.svelte.ts | 7 +- .../copilot/chat/script/MermaidDisplay.svelte | 2 +- .../lib/components/flows/FlowEditor.svelte | 39 +- .../lib/components/flows/agentDraft.svelte.ts | 363 ++++++++++++++ .../components/flows/agentEditStore.svelte.ts | 103 ---- .../components/flows/agentEditStore.test.ts | 93 ---- .../flows/agentEditorStore.svelte.ts | 63 +++ .../components/flows/agentFormFields.test.ts | 126 +++++ .../lib/components/flows/agentFormFields.ts | 232 +++++++++ .../flows/agentResourceUtils.test.ts | 65 ++- .../components/flows/agentResourceUtils.ts | 74 +-- .../lib/components/flows/agentToolUtils.ts | 26 +- .../flows/content/AgentEditorHost.svelte | 457 ++++++++++++++++++ .../flows/content/AgentEditorModal.svelte | 368 ++++++++++++++ .../flows/content/AgentResourceBar.svelte | 391 ++++----------- .../flows/content/AgentToolBindings.svelte | 47 +- .../flows/content/AgentToolRoster.svelte | 172 +++++++ .../flows/content/AgentToolWrapper.svelte | 24 +- .../flows/content/AiAgentStepInputs.svelte | 394 +++++++++++++++ .../content/FlowBranchesAllWrapper.svelte | 6 +- .../content/FlowBranchesOneWrapper.svelte | 6 +- .../flows/content/FlowEditorPanel.svelte | 1 + .../components/flows/content/FlowInput.svelte | 12 +- .../flows/content/FlowModuleComponent.svelte | 174 ++++--- .../flows/content/FlowModuleWrapper.svelte | 10 +- .../components/flows/flowDeleteController.ts | 4 +- .../src/lib/components/flows/flowInfers.ts | 40 +- .../src/lib/components/flows/flowState.ts | 7 +- .../components/flows/flowStateUtils.svelte.ts | 172 ++++++- .../flows/flowStoreRefresh.svelte.ts | 15 - frontend/src/lib/components/flows/idUtils.ts | 3 + .../flows/linkedAgentToolsStore.svelte.ts | 52 +- .../flows/linkedAgentToolsStore.test.ts | 52 +- .../flows/linkedToolsFetchGuard.test.ts | 4 +- .../flows/map/FlowErrorHandlerItem.svelte | 4 +- .../flows/map/FlowModuleSchemaMap.svelte | 222 +++------ .../flows/propPicker/OutputPickerInner.svelte | 23 +- frontend/src/lib/components/flows/types.ts | 4 + .../components/inputTransformEnv.svelte.ts | 41 ++ .../(logged)/flows/get/[...path]/+page.svelte | 8 +- .../(root)/(logged)/resources/+page.svelte | 114 ++++- frontend/src/routes/flows/dev/+page.svelte | 5 + 60 files changed, 3723 insertions(+), 1278 deletions(-) create mode 100644 frontend/src/lib/components/InputTransformPickers.svelte delete mode 100644 frontend/src/lib/components/aiEvals/AgentEvalModal.svelte create mode 100644 frontend/src/lib/components/flows/agentDraft.svelte.ts delete mode 100644 frontend/src/lib/components/flows/agentEditStore.svelte.ts delete mode 100644 frontend/src/lib/components/flows/agentEditStore.test.ts create mode 100644 frontend/src/lib/components/flows/agentEditorStore.svelte.ts create mode 100644 frontend/src/lib/components/flows/agentFormFields.test.ts create mode 100644 frontend/src/lib/components/flows/agentFormFields.ts create mode 100644 frontend/src/lib/components/flows/content/AgentEditorHost.svelte create mode 100644 frontend/src/lib/components/flows/content/AgentEditorModal.svelte create mode 100644 frontend/src/lib/components/flows/content/AgentToolRoster.svelte create mode 100644 frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte delete mode 100644 frontend/src/lib/components/flows/flowStoreRefresh.svelte.ts create mode 100644 frontend/src/lib/components/inputTransformEnv.svelte.ts diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index d948fee088..2c29721ef6 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -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 { + 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] diff --git a/frontend/src/lib/common.ts b/frontend/src/lib/common.ts index 259d9c1cf9..0d76b1a0b8 100644 --- a/frontend/src/lib/common.ts +++ b/frontend/src/lib/common.ts @@ -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 diff --git a/frontend/src/lib/components/AIProviderPicker.svelte b/frontend/src/lib/components/AIProviderPicker.svelte index 4392a2d513..19b1346b84 100644 --- a/frontend/src/lib/components/AIProviderPicker.svelte +++ b/frontend/src/lib/components/AIProviderPicker.svelte @@ -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([]) let filterText = $state('') - let modelsCache = new Map() + // 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() + + // 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 @@ }) -
- - - {#snippet children({ item })} - {#each providerOptions.slice(0, 3) as option} - - {/each} - p.value === value?.kind) >= 3 ? '' : 'More'} - togglableItems={providerOptions.slice(3)} - {item} - bind:selected={() => value?.kind, (v) => v && onProviderChange(v)} - /> - {/snippet} - - - -
-
-

resource

- resourceValueToPath(value?.resource), - (v) => { - if (value) { - value.resource = pathToResourceValue(v) ?? '' - } +
+
+ Resource + + resourceValueToPath(value?.resource), + (v) => { + if (value) { + value.resource = pathToResourceValue(v) ?? '' } } - resourceType={value?.kind} - disabled={disabled || !value?.kind} - placeholder="Select resource" - selectFirst={true} - /> -
+ } + resourceType={providerResourceTypes} + {disabled} + {workspace} + placeholder="Select an AI provider resource" + selectFirst={false} + onValueChange={onResourcePicked} + /> +
- +
+ Model + 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 + Reasoning effort + value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)} + providerConfig={value} + {disabled} />
+ {/if} - - {#if value?.model} -
-

reasoning effort

- value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)} - providerConfig={value} - {disabled} - /> -
- {/if} - - -
- { - if (!e.detail) { - removeConfig() - } else { - saveConfig(value) - } - }} - /> -
+
+ { + if (!e.detail) { + removeConfig() + } else { + saveConfig(value) + } + }} + />
{@render actions?.()} diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index 8550a58b57..7f50794a52 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -1447,7 +1447,7 @@ {showSchemaExplorer} /> {:else if inputCat == 'ai-provider'} - + {:else if inputCat == 'email'}
+ + true} /> {/if} diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index d895cb4e2e..bfda6d6f18 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -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 { diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index d766c06603..c5ae65eda2 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -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}
{#if aiGen} {@render aiGen()} - {:else if enableAi} + {:else if enableAi && !noJavascript} {/if} - {#if propPickerWrapperContext} + {#if propPickerWrapperContext && !noConnect} {#snippet children({ item })} {#if fieldAllowsAi} + {/if} - {#if isStaticTemplate(inputCat)} + {#if staticTemplateOffered} {/if} - {#if codeInjectionDetected && propertyType == 'static'} + {#if noJavascript && propertyType !== 'javascript'} + + {:else if codeInjectionDetected && propertyType == 'static'}
- {#if argName && schema?.properties?.[argName]?.description} + {#if fieldDescription}
-										{schema.properties[argName].description}
+										{fieldDescription}
 									
{/if} - {:else if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle} + {:else if staticTemplateOffered && propertyType == 'static' && !noDynamicToggle}
- {#if argName && schema?.properties?.[argName]?.description} + {#if fieldDescription}
-										{schema.properties[argName].description}
+										{fieldDescription}
 										
{/if} @@ -868,6 +935,8 @@ { 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}
-
{schema.properties[argName].description}
+
{fieldDescription}
{/if} diff --git a/frontend/src/lib/components/InputTransformPickers.svelte b/frontend/src/lib/components/InputTransformPickers.svelte new file mode 100644 index 0000000000..199039fb67 --- /dev/null +++ b/frontend/src/lib/components/InputTransformPickers.svelte @@ -0,0 +1,68 @@ + + + { + 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()} +
+ +
+ {/snippet} +
+ + diff --git a/frontend/src/lib/components/InputTransformSchemaForm.svelte b/frontend/src/lib/components/InputTransformSchemaForm.svelte index d0d29b3111..465e173f1f 100644 --- a/frontend/src/lib/components/InputTransformSchemaForm.svelte +++ b/frontend/src/lib/components/InputTransformSchemaForm.svelte @@ -1,17 +1,16 @@
- {#if enableAi} + + {#if enableAi && !isAgentTool}
- { - 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()} -
- -
- {/snippet} -
- - + diff --git a/frontend/src/lib/components/ModulePreviewForm.svelte b/frontend/src/lib/components/ModulePreviewForm.svelte index 88b99a05c5..72bb9fe36b 100644 --- a/frontend/src/lib/components/ModulePreviewForm.svelte +++ b/frontend/src/lib/components/ModulePreviewForm.svelte @@ -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; 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 }) + ?.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)}