From 49d0310ecc08040b6c6fa4f402584543d679e2e8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 16 Sep 2026 12:03:32 +0200 Subject: [PATCH 01/22] fix(apps): re-check access in place after a password sign-in (#11166) * fix(apps): re-check access in place after a password sign-in Co-Authored-By: Claude Opus 5 (1M context) * style: drop redundant comment in the password sign-in hand-back Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/lib/components/Login.svelte | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index da032717f6..44ba7e4e15 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -61,6 +61,10 @@ popup?: boolean firstTime?: boolean autoRedirect?: boolean + /** Supplying this replaces the post-login redirect: the card hands back instead of + * navigating, and the host is expected to re-check access in place. For a gate on a + * page that stays mounted, `rd` is the URL already shown, so navigating there would + * re-run nothing. */ onLoginSuccess?: () => void /** A refusal the popup relayed back, in the server's words. */ onLoginError?: (message: string) => void @@ -290,6 +294,11 @@ // Finally, we check whether the user is a superadmin refreshSuperadmin() + + if (onLoginSuccess) { + onLoginSuccess() + return + } redirectUser() } From 6885226c2686ee2d44d87b75107493aca41cf5dd Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 16 Sep 2026 12:05:22 +0200 Subject: [PATCH 02/22] collapse detail page header actions into menus below lg (#11163) Co-authored-by: Claude Fable 5.1 --- .../src/lib/components/DropdownV2Inner.svelte | 17 ++- .../components/common/button/Button.svelte | 2 + .../details/DetailPageHeader.svelte | 119 +++++++++++++----- .../details/ErrorHandlerToggleButton.svelte | 53 ++------ .../components/details/errorHandlerToggle.ts | 39 ++++++ frontend/src/lib/utils.ts | 2 + frontend/src/lib/utils/editInFork.ts | 13 ++ .../(logged)/flows/get/[...path]/+page.svelte | 7 ++ .../scripts/get/[...hash]/+page.svelte | 7 ++ 9 files changed, 179 insertions(+), 80 deletions(-) create mode 100644 frontend/src/lib/components/details/errorHandlerToggle.ts diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 5f8b96c32f..7749310b5b 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -53,12 +53,17 @@ {#if item.icon} {/if} -

- {item.displayName} -

+
+

+ {item.displayName} +

+ {#if item.description} +

{item.description}

+ {/if} +
{@render item.extra?.()} {#if item.shortcut || item.selected || item.toggle !== undefined} +
-
- {#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'} - - {stepDetail.id} - - {/if} - - {#if stepDetail.summary} - {stepDetail.summary} - {:else if stepDetail.value.type == 'identity'} - Identity - {:else if stepDetail.value.type == 'forloopflow'} - For loop {#if stepDetail.value.parallel}(parallel){/if} - {#if stepDetail.value.skip_failures}(skip failures){/if} - {#if stepDetail.value.squash}(squash){/if} - {:else if stepDetail.value.type == 'branchall'} - Run all branches {#if stepDetail.value.parallel}(parallel){/if} - {:else if stepDetail.value.type == 'branchone'} - Run one branch - {:else if stepDetail.value.type == 'flow'} - Inner flow - {:else if stepDetail.value.type == 'whileloopflow'} - While loop {#if stepDetail.value.skip_failures}(skip failures){/if} - {#if stepDetail.value.squash}(squash){/if} - {:else if stepDetail.id === 'failure'} - Error handler - {:else if stepDetail.id === 'preprocessor'} - Preprocessor - {:else if stepDetail.value.type == 'rawscript'} - Inline {stepDetail.value.language} script - {:else if stepDetail.value.type == 'script'} - Workspace script - {:else if stepDetail.value.type == 'aiagent'} - AI Agent - {/if} - -
{#if stepDetail.value.type == 'script'}
+ import type { FlowModule } from '$lib/gen' + import { Badge, Button } from './common' + import { ArrowLeft } from 'lucide-svelte' + + interface Props { + /** A module, or the graph's pseudo-nodes by id (`Input`, `Result`). */ + stepDetail: FlowModule | string + /** Given, the row starts with a back control; the caller decides where back leads. */ + onBack?: () => void + } + + let { stepDetail, onBack = undefined }: Props = $props() + + const module = $derived(typeof stepDetail === 'string' ? undefined : stepDetail) + // The error handler and the preprocessor are named by their role, not by an id badge. + const showId = $derived( + module?.id !== undefined && module.id !== 'failure' && module.id !== 'preprocessor' + ) + + const title = $derived.by((): string => { + if (typeof stepDetail === 'string') { + if (stepDetail === 'Input') return 'Flow inputs' + if (stepDetail === 'Result') return 'Result' + return stepDetail + } + if (stepDetail.summary) return stepDetail.summary + if (stepDetail.id === 'failure') return 'Error handler' + if (stepDetail.id === 'preprocessor') return 'Preprocessor' + const v = stepDetail.value + switch (v?.type) { + case 'identity': + return 'Identity' + case 'forloopflow': + return ( + 'For loop' + + (v.parallel ? ' (parallel)' : '') + + (v.skip_failures ? ' (skip failures)' : '') + + (v.squash ? ' (squash)' : '') + ) + case 'whileloopflow': + return ( + 'While loop' + (v.skip_failures ? ' (skip failures)' : '') + (v.squash ? ' (squash)' : '') + ) + case 'branchall': + return 'Run all branches' + (v.parallel ? ' (parallel)' : '') + case 'branchone': + return 'Run one branch' + case 'flow': + return 'Inner flow' + case 'rawscript': + return `Inline ${v.language} script` + case 'script': + return 'Workspace script' + case 'aiagent': + return 'AI Agent' + default: + return stepDetail.id + } + }) + + + +
+ {#if onBack} +
diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 77c802dcd2..4f91f1800f 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -137,7 +137,7 @@
diff --git a/frontend/src/lib/components/details/DetailPageLayout.svelte b/frontend/src/lib/components/details/DetailPageLayout.svelte index be6e1a9e79..a6a2aa7a8a 100644 --- a/frontend/src/lib/components/details/DetailPageLayout.svelte +++ b/frontend/src/lib/components/details/DetailPageLayout.svelte @@ -1,5 +1,6 @@
@@ -54,7 +74,7 @@
- {@render form?.()} + {@render form?.({ graphInline: true })} @@ -65,7 +85,11 @@ {@render save_inputs_render?.()} {/snippet} {#snippet flow_step()} - {@render flow_step_render?.()} + +
+ {@render flow_step_render?.({})} +
{/snippet} {#snippet triggers()} {@render triggers_render?.()} @@ -79,12 +103,15 @@
{@render header?.()}
- + + {#if !isChatMode} {/if} - {#if isChatMode && flow_json} + {#if flow_json} {/if} {#if !isOperator} @@ -99,7 +126,7 @@ {#snippet content()}
- {@render form?.()} + {@render form?.({ graphInline: false })} @@ -108,9 +135,9 @@ {@render triggers?.()} - {#if isChatMode && flow_json} + {#if flow_json} - {@render flow_graph_render?.()} + {@render pagedGraph()} {/if} {#if flow_json} @@ -128,3 +155,34 @@
{/if}
+ + +{#snippet pagedGraph()} + { + if (key === 'graph') selected = 'saved_inputs' + }} + pages={[ + { key: 'graph', content: graphPageContent }, + { key: 'step', content: stepPageContent } + ]} + /> +{/snippet} + +{#snippet graphPageContent()} +
+ {@render flow_graph_render?.()} +
+{/snippet} + +{#snippet stepPageContent()} + +
+ {@render flow_step_render?.({ onBack: () => (selected = 'saved_inputs') })} +
+{/snippet} diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index 59ed08c8b8..a1384579af 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -491,6 +491,7 @@ let stepDetail: FlowModule | string | undefined = $state(undefined) let rightPaneSelected = $state('saved_inputs') let savedInputsV2: SavedInputsV2 | undefined = $state(undefined) + let detailLayout: DetailPageLayout | undefined = $state(undefined) let flowHistory: FlowHistory | undefined = $state(undefined) let path = $derived(page.params.path ?? '') @@ -546,6 +547,7 @@ {/if} { - rightPaneSelected = 'triggers' + detailLayout?.showTriggers() }} {mainButtons} menuItems={getMenuItems(flow, deployUiSettings)} @@ -596,7 +598,7 @@ isFlow selected={rightPaneSelected == 'triggers'} onSelect={async (triggerIndex: number) => { - rightPaneSelected = 'triggers' + detailLayout?.showTriggers() await tick() triggersState.selectedTriggerIndex = triggerIndex }} @@ -627,7 +629,7 @@ {/if} {/snippet} - {#snippet form()} + {#snippet form({ graphInline }: { graphInline: boolean })}
(showEditButtons = v)} />
@@ -771,7 +773,7 @@ {/if}
- {#if !chatInputEnabled} + {#if graphInline}
{ - rightPaneSelected = 'triggers' + detailLayout?.showTriggers() }} noBorder={true} /> @@ -817,10 +819,10 @@ /> {/snippet} - {#snippet flow_step()} + {#snippet flow_step({ onBack }: { onBack?: () => void })} {#if flow} {#if stepDetail} - + {/if} {/if} {/snippet} @@ -849,7 +851,7 @@ triggerNode={true} download {flow} - noSide={false} + noSide={true} noBorder minHeight={flowGraphHeight} on:select={(e) => { @@ -862,7 +864,7 @@ } }} on:triggerDetail={(e) => { - rightPaneSelected = 'triggers' + detailLayout?.showTriggers() }} />
From 02e47de8b4c4f3f54753aabf8c67bc8e71ffb957 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 16 Sep 2026 17:28:40 +0200 Subject: [PATCH 06/22] feat: allow git sync auto-pull, promotion and PRs on Pro licenses (#11173) Co-authored-by: Claude Opus 5 --- backend/src/monitor.rs | 18 +--- .../windmill-api-workspaces/src/workspaces.rs | 92 +------------------ .../windmill-worker/src/result_processor.rs | 12 --- .../git_sync/GitSyncRepositoryCard.svelte | 5 +- 4 files changed, 10 insertions(+), 117 deletions(-) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index dc3b9b6e8b..e250302209 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -4712,17 +4712,11 @@ const GIT_AUTO_PULL_LOCK_ID: i64 = 737_483_921; /// Poll every git-sync repository with auto-pull enabled and enqueue a pull when /// the tracked branch has new commits (repo → Windmill direction). /// -/// Runs on a single replica at a time (advisory lock) and only on -/// Enterprise-licensed instances. Detection is `git ls-remote`; GitHub-App -/// repositories are skipped here and sync via webhooks instead (phase 2). +/// Runs on a single replica at a time (advisory lock). Detection is +/// `git ls-remote`; GitHub-App repositories are skipped here and sync via +/// webhooks instead (phase 2). #[cfg(feature = "private")] pub async fn poll_git_auto_pull(db: &Pool) { - use windmill_common::ee_oss::{get_license_plan, LicensePlan}; - - if !matches!(get_license_plan().await, LicensePlan::Enterprise) { - return; - } - let mut lock_conn = match db.acquire().await { Ok(c) => c, Err(e) => { @@ -4792,12 +4786,6 @@ const GIT_CREDENTIAL_LOCK_ID: i64 = 737_483_923; /// sync down on its expiry date. #[cfg(all(feature = "enterprise", feature = "private"))] async fn maintain_git_credentials(db: &Pool) { - use windmill_common::ee_oss::{get_license_plan, LicensePlan}; - - if !matches!(get_license_plan().await, LicensePlan::Enterprise) { - return; - } - // Transaction-scoped advisory lock, as for the schedule reconcile above: a // session lock on a pooled connection would ride back into the pool still // held if the sweep died before unlocking, and wedge the pass on every diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 3e48e7baca..2bef0d28ab 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -1308,26 +1308,22 @@ async fn get_git_sync_deploy_mode( let configured = !settings.repositories.is_empty(); - // Auto-pull runs only on Enterprise-licensed instances (see poll_git_auto_pull); - // without a caller branch there is nothing to match. Either way deploy_on_push - // stays false and the caller falls back (git push via CI, or wmill sync push). + // Auto-pull runs only in builds that compile the poller (`private`); without a + // caller branch there is nothing to match. Either way deploy_on_push stays + // false and the caller falls back (git push via CI, or wmill sync push). let Some(branch) = q.branch.as_deref() else { return Ok(Json(GitSyncDeployMode { configured, deploy_on_push: false, })); }; - let licensed = matches!( - windmill_common::ee_oss::get_license_plan().await, - windmill_common::ee_oss::LicensePlan::Enterprise - ); // Count the auto-pull repos that would deploy this branch. We deliberately do // not check the caller's remote URL: with exactly one such repo the local // checkout is unambiguously it, and with several we can't tell which is the // caller's, so we report false and let the CLI ask the user. let mut matches = 0u32; - if licensed && !root_deleted { + if cfg!(feature = "private") && !root_deleted { for repo in &settings.repositories { let Some(auto_pull) = repo.auto_pull.as_ref() else { continue; @@ -3762,54 +3758,6 @@ fn cleanup_legacy_git_sync_settings_in_memory( #[cfg(not(feature = "enterprise"))] const CE_GIT_SYNC_MAX_USERS: i64 = 2; -/// Auto-pull is licensed per plan, not just per build: the poller only serves -/// Enterprise plans at runtime, so the save path must reject the setting too — -/// otherwise an EE binary without the plan could still register a webhook and -/// receive webhook-driven pulls. -#[cfg(feature = "enterprise")] -async fn check_git_sync_ee_license(feature: &str) -> Result<()> { - if !matches!( - windmill_common::ee_oss::get_license_plan().await, - windmill_common::ee_oss::LicensePlan::Enterprise - ) { - return Err(Error::BadRequest(format!( - "{feature} requires an Enterprise license" - ))); - } - Ok(()) -} - -#[cfg(feature = "enterprise")] -async fn check_auto_pull_license() -> Result<()> { - check_git_sync_ee_license("Automatic pull from git").await -} - -/// In-app PR creation (promotion/fork deploy branches) drives GitHub API calls -/// from the deploy completion hook; runtime-gate it like auto-pull. -#[cfg(feature = "enterprise")] -async fn check_open_prs_license<'a>( - mut repos: impl Iterator, -) -> Result<()> { - if repos.any(|r| r.promotion_open_prs || r.fork_open_prs) { - check_git_sync_ee_license("Opening pull requests from Windmill").await?; - } - Ok(()) -} - -/// Promotion mode (`use_individual_branch`: per-item `wm_deploy/**` deploy -/// branches) is an EE feature; runtime-gate it like auto-pull and PR creation -/// so an enterprise binary without an active plan can't enable it via either -/// git-sync edit endpoint. -#[cfg(feature = "enterprise")] -async fn check_promotion_license<'a>( - mut repos: impl Iterator, -) -> Result<()> { - if repos.any(|r| r.use_individual_branch.unwrap_or(false)) { - check_git_sync_ee_license("Promotion mode").await?; - } - Ok(()) -} - /// Promotion on a dev workspace needs the dev-aware sync script (hub >= 28796): /// an older pinned script bundles a CLI that force-disables per-item branches /// on every fork, so enabling promotion would silently keep deploying to the @@ -4061,18 +4009,6 @@ async fn edit_git_sync_config( )); } #[cfg(feature = "enterprise")] - if git_sync_settings - .repositories - .iter() - .any(|r| r.auto_pull.as_ref().is_some_and(|a| a.enabled)) - { - check_auto_pull_license().await?; - } - #[cfg(feature = "enterprise")] - check_open_prs_license(git_sync_settings.repositories.iter()).await?; - #[cfg(feature = "enterprise")] - check_promotion_license(git_sync_settings.repositories.iter()).await?; - #[cfg(feature = "enterprise")] check_dev_promotion_script_version(&db, &w_id, git_sync_settings.repositories.iter()) .await?; #[cfg(all(feature = "enterprise", feature = "private"))] @@ -4310,19 +4246,6 @@ async fn edit_git_sync_repository( )); } #[cfg(feature = "enterprise")] - if new_config - .repository - .auto_pull - .as_ref() - .is_some_and(|a| a.enabled) - { - check_auto_pull_license().await?; - } - #[cfg(feature = "enterprise")] - check_open_prs_license(std::iter::once(&new_config.repository)).await?; - #[cfg(feature = "enterprise")] - check_promotion_license(std::iter::once(&new_config.repository)).await?; - #[cfg(feature = "enterprise")] check_dev_promotion_script_version(&db, &w_id, std::iter::once(&new_config.repository)).await?; #[cfg(all(feature = "enterprise", feature = "private"))] check_dev_promotion_targets_parent_repo(&db, &w_id, std::iter::once(&new_config.repository)) @@ -4428,13 +4351,6 @@ async fn edit_git_sync_repository( } _ => {} } - // The request-side license gate above only saw the submitted config; the - // preservation can resurrect an enabled auto_pull (None arm), so re-check - // the effective state before it gets written and reconciled. - #[cfg(feature = "enterprise")] - if updated.auto_pull.as_ref().is_some_and(|a| a.enabled) { - check_auto_pull_license().await?; - } *existing_repo = updated; } else { // Repository doesn't exist, add it as a new repository diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 1529d474fa..0490ed1754 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -1254,18 +1254,6 @@ async fn maybe_open_git_sync_deploy_pr( if row.marker.is_none() { return; } - // Runtime Enterprise gate, like the poller: the toggles may have been set - // while a license was active (or written directly), and this hook drives - // GitHub API calls with the installation token. - if !matches!( - windmill_common::ee_oss::get_license_plan().await, - windmill_common::ee_oss::LicensePlan::Enterprise - ) { - tracing::warn!( - "git sync PR: skipping PR creation for {workspace_id}: requires an Enterprise license" - ); - return; - } let Some(repo_path) = row.repo_path else { return; }; diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte index 32a510c54a..291c89c606 100644 --- a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -114,8 +114,9 @@ try { await gitSyncContext.saveRepository(idx) } catch (e) { - // The backend rejects promotion mode without an active EE plan; revert - // the optimistic toggle instead of leaving it stuck on until reload. + // The backend can reject promotion mode (non-EE build, outdated dev sync + // script, dev repo not matching its parent's); revert the optimistic + // toggle instead of leaving it stuck on until reload. if (repo) { repo.use_individual_branch = prevIndiv repo.group_by_folder = prevGbf From 9d348f84c7830f36b6153472556fd70e3d84cd24 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 16 Sep 2026 17:28:53 +0200 Subject: [PATCH 07/22] fix: skip expiry notifications for app embed and SDK tokens (#11169) * fix: skip expiry notifications for app embed and SDK tokens Co-Authored-By: Claude Opus 5 * refactor: share app token label prefixes between mint sites and the check Co-Authored-By: Claude Opus 5 * fix: skip expiry alerts for impersonation and test-connection tokens Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- AGENTS.md | 5 +-- ...f1d9f850e3e46c17130dd580426cda7a9f65.json} | 4 +-- backend/windmill-api-auth/src/lib.rs | 3 ++ .../tests/token_label_idor.rs | 4 ++- backend/windmill-api-users/src/users.rs | 7 ++-- backend/windmill-api/src/apps.rs | 12 +++++-- backend/windmill-common/src/auth.rs | 33 +++++++++++++++---- docs/auth-surface.md | 8 +++++ .../src/lib/components/TestConnection.svelte | 2 +- .../components/settings/TokensTable.svelte | 5 ++- 10 files changed, 64 insertions(+), 19 deletions(-) rename backend/.sqlx/{query-31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1.json => query-d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65.json} (63%) diff --git a/AGENTS.md b/AGENTS.md index 8a63c9828a..a315f1a67c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,9 @@ Open-source platform for internal tools, workflows, API integrations, background `cargo run`; a normal build cannot start one at all. - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation - scope, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`. - Read before designing anything that creates users, tokens or sessions. + scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and + that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates + users, tokens or sessions. - **Product telemetry**: `docs/feature-telemetry.md` — when to instrument a new feature with `feature_usage`, and the four-step recipe. An unregistered `(feature, kind)` pair is dropped silently, so frontend-only instrumentation records nothing. diff --git a/backend/.sqlx/query-31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1.json b/backend/.sqlx/query-d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65.json similarity index 63% rename from backend/.sqlx/query-31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1.json rename to backend/.sqlx/query-d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65.json index 556bd7c317..8cef9fc8aa 100644 --- a/backend/.sqlx/query-31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1.json +++ b/backend/.sqlx/query-d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix", + "query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n ))\n RETURNING token_prefix", "describe": { "columns": [ { @@ -20,5 +20,5 @@ false ] }, - "hash": "31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1" + "hash": "d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65" } diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 04513b4c05..0e26c2a8b8 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -1141,6 +1141,9 @@ impl NewToken { /// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally /// skip it, since their scopes derive from the action being authorized, not the /// caller's token). +/// +/// A token the system mints for itself with an `expiration` needs a label reserved in +/// `windmill_common::auth::is_user_token`, or its expiry alerts its owner (docs/auth-surface.md). pub async fn create_token_internal( tx: &mut sqlx::PgConnection, db: &DB, diff --git a/backend/windmill-api-integration-tests/tests/token_label_idor.rs b/backend/windmill-api-integration-tests/tests/token_label_idor.rs index ddec1a481a..e690bf673f 100644 --- a/backend/windmill-api-integration-tests/tests/token_label_idor.rs +++ b/backend/windmill-api-integration-tests/tests/token_label_idor.rs @@ -179,9 +179,11 @@ async fn test_trigger_token_labels_still_creatable(db: Pool) -> anyhow "http-test-user-2-cd34", "email-test-user-2-ef56", "my-ci-token", - // Minted client-side by the editor (every TypeScript editor load) and the debugger. + // Minted client-side by the editor (every TypeScript editor load), the debugger and + // the object-storage "Test from a worker" button. "Ephemeral lsp token", "debugger-token", + "ephemeral-test-connection: s3_bucket", ] { let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await; assert_eq!( diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 476a900ef6..b7d30eaa4b 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -3882,8 +3882,8 @@ async fn update_token_label( Path(token_prefix): Path, Json(req): Json, ) -> Result { - // The new label must not collide with a system-token namespace (`session`, - // `ephemeral*`, `debugger-token`, `mcp-oauth-*`): those labels are + // The new label must not collide with a system-token namespace (see + // `windmill_common::auth::is_user_token`): those labels are // load-bearing, and a user-set collision would orphan the token — hidden // from the UI (`isUserToken`) and rejected by the editability guard below — // while it still authenticates. (`is_user_token(None)` is true, so clearing @@ -3922,6 +3922,9 @@ async fn update_token_label( AND lower(label) NOT LIKE 'ephemeral%' AND label <> 'debugger-token' AND label NOT LIKE 'mcp-oauth-%' + AND NOT starts_with(label, 'embed_app:') + AND NOT starts_with(label, 'sdk_app:') + AND NOT starts_with(label, 'impersonation:') )) RETURNING token_prefix", req.label.as_deref(), diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 6af6bbc37c..8b85c2919b 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -57,7 +57,7 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::{ apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE}, - auth::TOKEN_PREFIX_LEN, + auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN}, cache::{self, future::FutureCachedExt}, db::{DbWithOptAuthed, UserDB}, error::{to_anyhow, Error, JsonResult, Result}, @@ -1522,7 +1522,10 @@ async fn mint_raw_app_sdk_token( scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string()); (label, exp) } - None => (format!("sdk_app:{app_path}"), requested_exp), + None => ( + format!("{RAW_APP_SDK_TOKEN_LABEL_PREFIX}{app_path}"), + requested_exp, + ), }; let token_config = NewToken::new( Some(label), @@ -1804,7 +1807,10 @@ pub async fn mint_app_embed_token( scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string()); (label, exp) } - None => (format!("embed_app:{app_path}"), requested_exp), + None => ( + format!("{APP_EMBED_TOKEN_LABEL_PREFIX}{app_path}"), + requested_exp, + ), }; let token_config = NewToken::new( Some(label), diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 73cdfba35d..3e8eb58a14 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -19,10 +19,11 @@ use crate::{ }; /// Whether `label` denotes a user-created token rather than a system token -/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token -/// labels are load-bearing — session cleanup, super_admin propagation, expiry -/// notifications and username overrides all key off them — so they must not be -/// user-editable. `None` (no label) is treated as a user token. +/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`, +/// `embed_app:*`, `sdk_app:*`, `impersonation:*`). System-token labels are load-bearing — +/// session cleanup, super_admin propagation, expiry notifications and username overrides +/// all key off them — so they must not be user-editable. `None` (no label) is treated as +/// a user token. /// /// This is the canonical copy. When updating it, also update its mirrors: /// - the `update_token_label` editability guard (SQL `WHERE`) in @@ -40,15 +41,30 @@ pub fn is_user_token(label: Option<&str>) -> bool { && !l.to_lowercase().starts_with("ephemeral") && l != "debugger-token" && !l.starts_with("mcp-oauth-") + // Short-lived tokens the server mints per app open or per service-account + // impersonation (EE `users_ee.rs`) and nobody manages, so an expiry warning + // for one is noise. + && !l.starts_with(APP_EMBED_TOKEN_LABEL_PREFIX) + && !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX) + && !l.starts_with("impersonation:") } } } +/// Label prefix, followed by the app path, of the token an app viewer's sandboxed iframe +/// runs with. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out. +pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:"; + +/// Label prefix, followed by the app path, of the token a raw app's bundle uses for the +/// frontend SDK. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out. +pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:"; + /// Whether `label` belongs to a namespace only the server mints, and which therefore must be /// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label -/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token` -/// and `debugger-token` are minted by the editor and the debugger through that same handler, -/// so reserving them would break those features. +/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`, +/// `debugger-token` and `ephemeral-test-connection: *` are minted by the editor, the debugger +/// and object-storage connection tests through that same handler, so reserving them would +/// break those features. /// /// `username_override_from_label` trusts a label to name the entity acting only if it is in /// here, so anything added must be unmintable by a member. @@ -961,6 +977,9 @@ mod tests { assert!(!is_user_token(Some("Ephemeral lsp token"))); assert!(!is_user_token(Some("debugger-token"))); assert!(!is_user_token(Some("mcp-oauth-client"))); + assert!(!is_user_token(Some("embed_app:f/team/dashboard"))); + assert!(!is_user_token(Some("sdk_app:u/admin/raw app"))); + assert!(!is_user_token(Some("impersonation:admin@windmill.dev"))); } #[test] diff --git a/docs/auth-surface.md b/docs/auth-surface.md index 0876a1184a..abeba1fe2b 100644 --- a/docs/auth-surface.md +++ b/docs/auth-surface.md @@ -12,6 +12,14 @@ Symbols, not line numbers, are cited: they drift less. by `create_session_token` (`windmill-api-users/src/users.rs`). `GET /api/users/refresh_token` mints one for any non-job token but returns plain text, no redirect. - **`tokens/impersonate`** (superadmin) returns a multi-use token and sets no cookie. +- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items` + removes an expired `token` row, the monitor emails the owner and raises a critical alert (if + enabled); rows registered by `register_token_expiry_notification` also get an "expiring soon" + warning first. Neither happens when `is_user_token` (`windmill-common/src/auth.rs`) reserves + the label, so a token the system mints for itself, whether from the backend or from the frontend + through `tokens/create`, needs a reserved label. An `ephemeral-` prefix needs no other change + (keep it clear of `is_server_minted_label` if minted through `tokens/create`); a new prefix + also goes into the SQL and Svelte mirrors that function's doc lists. - **Every superadmin route refuses a job token**: `require_super_admin` (`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs `users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user diff --git a/frontend/src/lib/components/TestConnection.svelte b/frontend/src/lib/components/TestConnection.svelte index 6a58af8031..80ec59d206 100644 --- a/frontend/src/lib/components/TestConnection.svelte +++ b/frontend/src/lib/components/TestConnection.svelte @@ -174,7 +174,7 @@ export async function main(bucket: any, api_token: string) { async function mintApiToken(): Promise { return await UserService.createToken({ requestBody: { - label: `test connection: ${resourceType}`, + label: `ephemeral-test-connection: ${resourceType}`, expiration: new Date(Date.now() + API_TOKEN_TTL_MS).toISOString(), scopes: ['settings:write'] } diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index fe85d85f03..abfdd0f36d 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -59,7 +59,10 @@ label !== 'guest_session' && !label.toLowerCase().startsWith('ephemeral') && label !== 'debugger-token' && - !label.startsWith('mcp-oauth-') + !label.startsWith('mcp-oauth-') && + !label.startsWith('embed_app:') && + !label.startsWith('sdk_app:') && + !label.startsWith('impersonation:') ) } From a9ec0aec3ac0c6b0f7919d0eb2168816923826d7 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 16 Sep 2026 17:45:01 +0200 Subject: [PATCH 08/22] feat: render the flow chat through the shared session chat components (#11175) * feat: render the flow chat through the shared session chat components Co-Authored-By: Claude Fable 5.1 * fix: settle the flow chat queue on the turn's outcome and guard IME enter Co-Authored-By: Claude Fable 5.1 * fix: keep copilot free-tier banners and deploy-locked sends out of the flow chat Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- .../copilot/chat/AIChatDisplay.svelte | 235 +++++++------ .../copilot/chat/AIChatInput.svelte | 195 ++++++----- .../copilot/chat/AIChatManager.svelte.ts | 21 +- .../copilot/chat/AIChatMessage.svelte | 25 +- .../chat/AskUserQuestionDisplay.svelte | 10 +- .../copilot/chat/AssistantMessage.svelte | 25 +- .../copilot/chat/ContextTextarea.svelte | 23 +- .../copilot/chat/ContextUsageIndicator.svelte | 10 +- .../copilot/chat/QueuedMessageChip.svelte | 34 +- .../chat/ToolConfirmationFooter.svelte | 6 +- .../copilot/chat/ToolExecutionDisplay.svelte | 8 +- .../components/copilot/chat/chatViewHost.ts | 152 +++++++++ .../components/copilot/chat/composerBox.ts | 27 ++ .../src/lib/components/copilot/chat/shared.ts | 8 + .../flows/conversations/FlowChat.svelte | 28 +- .../conversations/FlowChatInterface.svelte | 254 +++++++------- .../conversations/flowChatViewHost.svelte.ts | 310 ++++++++++++++++++ .../conversations/flowChatViewHost.test.ts | 267 +++++++++++++++ .../(logged)/flows/get/[...path]/+page.svelte | 2 + 19 files changed, 1241 insertions(+), 399 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/chatViewHost.ts create mode 100644 frontend/src/lib/components/copilot/chat/composerBox.ts create mode 100644 frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts create mode 100644 frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 7de7dd32dc..82dcbc2dcd 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -44,6 +44,7 @@ import Markdown from 'svelte-exmarkdown' import { twMerge } from 'tailwind-merge' import { AIAutonomyMode, AIMode } from './AIChatManager.svelte' + import { getChatViewHost } from './chatViewHost' import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import AIChatInput from './AIChatInput.svelte' @@ -68,14 +69,19 @@ import { base } from '$lib/base' const MAX_YOLO_TOOLTIP_TOOLS = 8 + const chatHost = getChatViewHost() + // The skill and MCP menus take an AIChatManager itself, which the seam deliberately + // doesn't carry. They render only under GLOBAL, which a non-copilot host never sets. const aiChatManager = getAiChatManager() + // The free grant pays for the copilot's own model, so its banners belong only to a host + // that sends to that model. A flow chat's turn runs on the flow's provider. + const freeTier = $derived(chatHost.supportsModelSettings ? $copilotInfo.freeTier : undefined) // The user spent their one-time free Windmill AI grant: there is no model left to send // to, so say so in the thread itself rather than only failing on send. - let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true) + let freeTierExhausted = $derived(freeTier?.exhausted === true) // Still on the free grant: keep how much is left in view right above the composer, so // running out isn't a surprise. Once spent, the exhausted banner replaces it. - let freeTier = $derived($copilotInfo.freeTier) let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100))) let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted) @@ -174,8 +180,12 @@ wideLayout = false, emptyHint, inputPreface, + footerSettings, initialInstructions = undefined, - onDraftChange = undefined + onDraftChange = undefined, + placeholder = undefined, + scrollElement = $bindable(), + onTranscriptScroll = undefined }: { messages: DisplayMessage[] pastChats: { id: string; title: string }[] @@ -202,9 +212,18 @@ wideLayout?: boolean emptyHint?: Snippet inputPreface?: Snippet + /** The settings control at the footer's right edge, where the copilot puts its + * model picker. A host that configures its turn elsewhere replaces it here. */ + footerSettings?: Snippet // Seed / observe the main composer's draft text (see AIChatInput). initialInstructions?: string onDraftChange?: (text: string) => void + /** Composer placeholder. Falls back to the per-AI-mode wording. */ + placeholder?: string + /** The transcript's scroll container. A host that paginates older messages + * needs it to measure and restore the scroll position. */ + scrollElement?: HTMLDivElement | undefined + onTranscriptScroll?: () => void } = $props() let aiChatInput: AIChatInput | undefined = $state() @@ -223,7 +242,7 @@ let panelEl: HTMLDivElement | undefined = $state() $effect(() => { function onWindowKeydownCapture(e: KeyboardEvent) { - if (e.key !== 'Escape' || !aiChatManager.loading) return + if (e.key !== 'Escape' || !chatHost.loading) return const active = document.activeElement const focusOnChat = !active || active === document.body || (panelEl?.contains(active) ?? false) @@ -231,22 +250,21 @@ // row alone stops the turn — wherever it is mounted, since the preview panel holds the // form outside `panelEl`. Matched by call: two chats can be loading at once, and one's // row must not answer for the other. - if (aiChatManager.hasPendingRunForm) { + if (chatHost.hasPendingRunForm) { const row = active?.closest('[data-run-form-actions]') const toolCallId = row?.getAttribute('data-run-form-actions') - if (!toolCallId || !aiChatManager.isRunFormPending(toolCallId)) return + if (!toolCallId || !chatHost.isRunFormPending(toolCallId)) return } else if (!focusOnChat) return e.preventDefault() // Immediate form: other chat panels' identical listeners must not // also cancel on body focus, nor a drawer/modal close on this press. e.stopImmediatePropagation() - aiChatManager.cancel() + chatHost.cancel() } window.addEventListener('keydown', onWindowKeydownCapture, true) return () => window.removeEventListener('keydown', onWindowKeydownCapture, true) }) - let scrollEl: HTMLDivElement | undefined = $state() // Programmatic-scroll guard. `scrollDown()` triggers an async `scroll` // event; if a token-append between the scrollTo and the dispatch makes // scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and @@ -259,22 +277,23 @@ // Instant scroll — smooth would animate every token append, racing with // the next scrollDown and confusing the onscroll bottom-detection below. function scrollDown() { - if (!scrollEl) return + if (!scrollElement) return programmaticScrollAt = Date.now() - scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'auto' }) + scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' }) } let height = $state(0) $effect(() => { - if (aiChatManager.automaticScroll && height) { + if (chatHost.automaticScroll && height) { scrollDown() } // Recompute the scroll-to-latest visibility on every content-height // change. `onScroll` only fires for actual scroll events, so without // this the arrow can go stale when content grows past the threshold // while auto-scroll is disabled (user scrolled up mid-stream). - if (scrollEl && height) { - const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight + if (scrollElement && height) { + const distance = + scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX } }) @@ -289,8 +308,9 @@ const SCROLL_TO_LATEST_THRESHOLD_PX = 200 let showScrollToLatest = $state(false) function onScroll() { - if (!scrollEl) return - const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight + if (!scrollElement) return + const distance = + scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight // Always refresh the arrow visibility — even during the cooldown, // because clicking the arrow itself triggers a programmatic scroll // whose only event would otherwise be swallowed, leaving the arrow @@ -303,14 +323,15 @@ return } if (distance <= STICK_TO_BOTTOM_PX) { - aiChatManager.enableAutomaticScroll() + chatHost.enableAutomaticScroll() } else { - aiChatManager.disableAutomaticScroll() + chatHost.disableAutomaticScroll() } + onTranscriptScroll?.() } function submitSuggestion(suggestion: string) { - aiChatManager.sendRequest({ instructions: suggestion }) + chatHost.sendRequest({ instructions: suggestion }) } export function focusInput() { @@ -319,35 +340,31 @@ $effect(() => { if (aiChatInput) { - aiChatManager.setAiChatInput(aiChatInput) + chatHost.setAiChatInput(aiChatInput) } return () => { - aiChatManager.setAiChatInput(null) + chatHost.setAiChatInput(null) } }) // Also shown for a run held by another tab, labeled with where it is: the // dots say a turn is in flight even before the reader reaches the footer // note. Remote runs pause nothing and offer no Stop — this tab can't cancel. - const showTypingIndicator = $derived(aiChatManager.loading || aiChatManager.runHeldElsewhere) + const showTypingIndicator = $derived(chatHost.loading || chatHost.runHeldElsewhere) // The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items + // code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there // `@`-context is still invoked inline by typing `@` in the input, so the button // is redundant. NAVIGATOR/ASK/API don't take @-context at all. const showContextPicker = $derived( - aiChatManager.mode === AIMode.SCRIPT || - aiChatManager.mode === AIMode.FLOW || - aiChatManager.mode === AIMode.APP + chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP ) - // File attachment is GLOBAL-mode only. - const canAttachFiles = $derived(aiChatManager.mode === AIMode.GLOBAL && !disabled) - // Steers the OS file picker toward text + image formats (soft hint; both attach - // to the message — text files after a content sniff). - const TEXT_FILE_ACCEPT = - 'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile' + const canAttachFiles = $derived(chatHost.supportsMessageAttachments && !disabled) + // Folders are linked as session-wide assets, which only a host that reads files in + // the browser can do — a host running the turn server-side takes attachments only. + const canLinkFolders = $derived(chatHost.supportsLinkedFolders && !disabled) let fileInputEl = $state(null) let folderInputEl = $state(null) let dragDepth = $state(0) @@ -373,12 +390,12 @@ } async function handleAddFiles(files: FileList | FileToAttach[]) { - const { added, rejected } = await aiChatManager.attachedFiles.addFiles(files) + const { added, rejected } = await chatHost.attachedFiles.addFiles(files) reportAddResult(added, rejected) } async function addDirHandle(dir: FileSystemDirectoryHandle) { - const { added, rejected } = await aiChatManager.attachedFiles.addFolder(dir) + const { added, rejected } = await chatHost.attachedFiles.addFolder(dir) reportAddResult(added, rejected) } @@ -471,8 +488,13 @@ const textFiles = looseFiles.filter((f) => !isImageFile(f)) if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles) // Folders link as a live handle. - for (const h of handles.filter(isDirectoryHandle)) { - await addDirHandle(h) + const dirs = handles.filter(isDirectoryHandle) + if (dirs.length > 0 && !canLinkFolders) { + sendUserToast('Folders cannot be attached in this chat — drop individual files.', true) + } else { + for (const h of dirs) { + await addDirHandle(h) + } } } else { // Fallback (no File System Access API): snapshot dropped files AND folders by walking @@ -497,7 +519,10 @@ topLevelText.push(file) } } - if (folderEntries.length > 0) await handleAddFiles(folderEntries) + if (folderEntries.length > 0) { + if (canLinkFolders) await handleAddFiles(folderEntries) + else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true) + } if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText) } } @@ -524,9 +549,9 @@ input.value = '' } const autonomyAvailability = $derived({ - autoAcceptEditsAvailable: aiChatManager.autoAcceptEditsAvailable, - autoAcceptToolConfirmationsAvailable: aiChatManager.autoAcceptToolConfirmationsAvailable, - planModeAvailable: aiChatManager.planModeAvailable + autoAcceptEditsAvailable: chatHost.autoAcceptEditsAvailable, + autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable, + planModeAvailable: chatHost.planModeAvailable }) const availableAutonomyModeOptions = $derived( autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability)) @@ -534,8 +559,8 @@ // Fall back to ask-permission when the persisted mode isn't applicable in the // current AI mode (e.g. auto-accept edits while in a mode without edits). const effectiveAutonomyMode = $derived( - availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode) - ? aiChatManager.autonomyMode + availableAutonomyModeOptions.some((option) => option.mode === chatHost.autonomyMode) + ? chatHost.autonomyMode : AIAutonomyMode.DEFAULT ) const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1) @@ -544,13 +569,13 @@ // The typing-dots indicator implies the AI is busy, which is misleading while // the loop is parked on the user; surface a text pill instead so users know to // act on the tool above. - const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages)) + const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages)) // Gated on `loading` because a card restored from history still looks parked: // its resolver left with the old page, so the composer must not advertise an // answer it cannot deliver. const pendingQuestionToolCallId = $derived.by(() => { - if (!aiChatManager.loading) { + if (!chatHost.loading) { return undefined } const pending = pendingUserActionDetail(messages) @@ -559,14 +584,14 @@ // Get app context for display when in APP mode const appContext = $derived.by((): SelectedContext | undefined => { - if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) { + if (chatHost.mode !== AIMode.APP || !chatHost.appAiChatHelpers) { return undefined } - return aiChatManager.appAiChatHelpers.getSelectedContext() + return chatHost.appAiChatHelpers.getSelectedContext() }) const yoloBypassedTools = $derived.by(() => { - return aiChatManager.tools + return chatHost.tools .filter((tool) => tool.requiresConfirmation === true || tool.bypassedByAutoAccept === true) .map((tool) => ({ name: tool.def.function.name, @@ -583,8 +608,7 @@ Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length) ) const showFlowPendingActionControls = $derived( - (aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) && - !aiChatManager.autoAcceptEditsActive + (chatHost.flowAiChatHelpers?.hasPendingChanges() ?? false) && !chatHost.autoAcceptEditsActive ) // A disabled state with no message (a remote hold, a spent free grant) keeps // the footer toolbar in place — swapping it for an empty strip would make @@ -592,11 +616,15 @@ // a real message (archived, AI off) still shows it, hold or not, matching // the precedence disabledMessage itself encodes. const footerMessageShown = $derived(disabled && disabledMessage !== '') + // `canAttachFiles` belongs in the group too: in GLOBAL mode the `+` always has the + // context picker or the autonomy selector beside it, but a host with attachments and + // nothing else would lose the group and the `+` with it. const showFooterLeftControls = $derived( !footerMessageShown && - (showContextPicker || + (canAttachFiles || + showContextPicker || showAutonomyModeSelector || - (aiChatManager.mode === AIMode.SCRIPT && hasDiff)) + (chatHost.mode === AIMode.SCRIPT && hasDiff)) ) @@ -694,12 +722,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {#each pastChats as chat (chat.id)}
{/if}
- {:else if aiChatManager.mode === AIMode.APP} + {:else if chatHost.mode === AIMode.APP} {#if showContext} {@render badgeRow()} {/if} @@ -1264,30 +1289,38 @@ {/if} {:else} -
- - {#if !bottomRightSnippet} -
- {@render sendStopButton()} -
- {/if} + +
+ {@render badgeRow()} + {@render imageChipsRow()} +
+ + {#if !bottomRightSnippet} +
+ {@render sendStopButton()} +
+ {/if} +
{/if} {#if bottomRightSnippet} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index aafb6a2ed1..34a6b5f3f1 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -1,3 +1,4 @@ +import type { ChatViewHost } from './chatViewHost' import type { ScriptLang } from '$lib/gen/types.gen' import { JobService, type CompletedJob } from '$lib/gen' import type { FlowOptions, ScriptOptions } from './ContextManager.svelte' @@ -445,9 +446,27 @@ function planModeHostFor(m: AIChatManager): PlanModeHost { } } -export class AIChatManager { +export class AIChatManager implements ChatViewHost { contextManager = new ContextManager() historyManager = new HistoryManager() + // The copilot owns its model choice and its own transcript, so both chat + // affordances apply here. See ChatViewHost for hosts where they don't. + supportsModelSettings = true + supportsMessageEditing = true + // The copilot turn is the attachments themselves when there is no text. + requiresMessageText = false + // Attachments and linked folders are GLOBAL-mode affordances. Declared as + // getters because `mode` changes under a mounted composer. + get supportsMessageAttachments() { + return this.mode === AIMode.GLOBAL + } + get supportsLinkedFolders() { + return this.mode === AIMode.GLOBAL + } + // Steers the OS file picker toward text + image formats (a soft hint; both attach to + // the message — text files after a content sniff). + attachmentAccept = + 'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile' /** Files the user attached to the current GLOBAL-mode conversation. */ attachedFiles = new AttachedFilesStore() /** Markdown artifacts the copilot created for the current session. */ diff --git a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte index 12553be961..afcffbb400 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte @@ -3,7 +3,7 @@ import type { DisplayMessage, ToolDisplayMessage } from './shared' import ContextElementBadge from './ContextElementBadge.svelte' import AssistantMessage from './AssistantMessage.svelte' - import { getAiChatManager } from './aiChatManagerContext' + import { getChatViewHost } from './chatViewHost' import { Button } from '$lib/components/common' import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte' import AIChatInput from './AIChatInput.svelte' @@ -15,7 +15,7 @@ import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte' import { workspaceStore } from '$lib/stores' - const aiChatManager = getAiChatManager() + const chatHost = getChatViewHost() // Paths in a message name items the chat's tools reach, so they resolve against the // operating workspace, never `workspaceStore`: a fork session leaves the store on the @@ -25,7 +25,7 @@ // Registers the dependency that `operatingWorkspace`'s own untracked // `get(workspaceStore)` cannot. void $workspaceStore - return aiChatManager.operatingWorkspace + return chatHost.operatingWorkspace }) // Per-message expand/collapse state for paste chips shown in the bubble. @@ -62,7 +62,12 @@ let editContext = $state([]) function editMessage() { - if (message.role !== 'user' || editingMessageIndex !== null || aiChatManager.loading) { + if ( + !chatHost.supportsMessageEditing || + message.role !== 'user' || + editingMessageIndex !== null || + chatHost.loading + ) { return } editContext = [...(message.contextElements ?? [])] @@ -79,7 +84,9 @@ message.role === 'tool' && 'mb-1', message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6', isLast && '!mb-12', - message.role !== 'user' ? 'cursor-default' : 'cursor-pointer' + message.role !== 'user' || !chatHost.supportsMessageEditing + ? 'cursor-default' + : 'cursor-pointer' )} role="button" tabindex="0" @@ -116,7 +123,7 @@ bind:selectedContext={editContext} initialInstructions={message.content} initialPastes={message.pastes} - initialImages={aiChatManager.storedImages(messageIndex)} + initialImages={chatHost.storedImages(messageIndex)} initialFiles={message.files} {editingMessageIndex} onClickOutside={() => (editingMessageIndex = null)} @@ -185,9 +192,9 @@ on:click={() => { if (message.snapshot) { if (message.snapshot.type === 'flow') { - aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value) + chatHost.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value) } else if (message.snapshot.type === 'app') { - aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value) + chatHost.appAiChatHelpers?.revertToSnapshot(message.snapshot.value) } } }} @@ -206,7 +213,7 @@ variant="default" title="Retry generation" startIcon={{ icon: RefreshCwIcon }} - onclick={() => aiChatManager.retryRequest(messageIndex)} + onclick={() => chatHost.retryRequest(messageIndex)} > Retry diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte index 403b42864d..17cc2bb4c1 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -4,7 +4,7 @@ import { CircleHelp, ArrowUp, Plus, Square, SquareCheck } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' - import { getAiChatManager } from './aiChatManagerContext' + import { getChatViewHost } from './chatViewHost' import type { UserQuestionDisplay } from './shared' // Sessions inject a per-pane `AIChatManager` via context; outside of @@ -12,7 +12,7 @@ // this, answers clicked inside a session would dispatch to the singleton's // pending callbacks map (which doesn't have the session manager's question // callback), and the AI loop would stall. - const aiChatManager = getAiChatManager() + const chatHost = getChatViewHost() interface Props { toolCallId: string @@ -93,14 +93,14 @@ } return } - aiChatManager.handleUserQuestionAnswer(toolCallId, [choice]) + chatHost.handleUserQuestionAnswer(toolCallId, [choice]) } function submitPicked() { if (!multiSelect || picked.size === 0) { return } - aiChatManager.handleUserQuestionAnswer(toolCallId, [...picked]) + chatHost.handleUserQuestionAnswer(toolCallId, [...picked]) } function submitCustomAnswer() { @@ -119,7 +119,7 @@ return } - aiChatManager.handleUserQuestionAnswer(toolCallId, [answer]) + chatHost.handleUserQuestionAnswer(toolCallId, [answer]) } function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) { diff --git a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte index f06941c2a4..44cedf323c 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte @@ -12,6 +12,7 @@ workspaceItemRegistry } from './workspaceItems.svelte' import { markdownProse } from '$lib/components/markdownProse' + import DisplayResult from '$lib/components/DisplayResult.svelte' interface Props { message: DisplayMessage @@ -60,6 +61,20 @@ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s` } + const stepName = $derived(message.role === 'assistant' ? message.stepName : undefined) + + // A flow step can return a file rather than text; the raw JSON would be + // unreadable, so hand it to the result viewer instead of the markdown renderer. + const s3Object = $derived.by(() => { + if (!message.content.startsWith('{')) return undefined + try { + const parsed = JSON.parse(message.content) + return parsed?.type === 'windmill_s3_object' && parsed?.s3 ? parsed : undefined + } catch { + return undefined + } + }) + const candidatePaths = $derived(extractCandidatePaths(message.content)) const rendererPlugin = { renderer: { @@ -98,6 +113,12 @@ }) +{#if stepName} +
+ {stepName} +
+{/if} + {#if reasoning} {/if} -{#if message.content} +{#if s3Object} + +{:else if message.content}
diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index 8ae3b51b56..976b969b3f 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -1,4 +1,5 @@ - - -
+
{@render leading?.()} @@ -830,11 +817,7 @@ {placeholder} class={twMerge( 'textarea-input resize-none caret-black dark:caret-white overflow-clip', - // The box (border/ring) lives on the wrapper; kill the textarea's own - // @tailwindcss/forms border, focus ring, and background so only the - // wrapper reads as the field. - '!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0', - 'disabled:cursor-not-allowed disabled:placeholder:text-disabled', + COMPOSER_FIELD_RESET, CHAT_INPUT_PADDING, className )} diff --git a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte index 96b452120b..cd6c58b2a3 100644 --- a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte @@ -1,16 +1,16 @@ -{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0 || (aiChatManager.queuedContext?.length ?? 0) > 0} +{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || (chatHost.queuedContext?.length ?? 0) > 0}
- {#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0} + {#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0} - {:else if aiChatManager.queuedContext?.length} + {:else if chatHost.queuedContext?.length}
- {#each aiChatManager.queuedContext as element (contextElementKey(element))} + {#each chatHost.queuedContext as element (contextElementKey(element))} {/each}
@@ -82,7 +82,7 @@ iconOnly title="Remove queued message and put it back in the input" startIcon={{ icon: X }} - on:click={() => aiChatManager.dequeueMessage()} + on:click={() => chatHost.dequeueMessage()} />
{/if} diff --git a/frontend/src/lib/components/copilot/chat/ToolConfirmationFooter.svelte b/frontend/src/lib/components/copilot/chat/ToolConfirmationFooter.svelte index 699b79a292..fcf943cb34 100644 --- a/frontend/src/lib/components/copilot/chat/ToolConfirmationFooter.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolConfirmationFooter.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 620357ff87..a0d16043a4 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -21,9 +21,9 @@ } from './planMode' import { Button } from '$lib/components/common' import { markdownProse } from '$lib/components/markdownProse' - import { getAiChatManager } from './aiChatManagerContext' + import { getChatViewHost } from './chatViewHost' - const aiChatManager = getAiChatManager() + const chatHost = getChatViewHost() import { isActiveUserQuestion, type ToolDisplayMessage } from './shared' import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' import { twMerge } from 'tailwind-merge' @@ -69,7 +69,7 @@ const planLabel = $derived((planState && planCopy?.[planState]) ?? '') const planDoc = $derived( message.planArtifactId - ? aiChatManager.artifacts.artifacts.find((a) => a.id === message.planArtifactId) + ? chatHost.artifacts.artifacts.find((a) => a.id === message.planArtifactId) : undefined ) // The version this card wrote, not the document's current one, since later proposals move it on. @@ -201,7 +201,7 @@ title="Open this plan in the side panel: {planDoc.name}" startIcon={{ icon: FileText, classes: PLAN_MODE_TEXT_COLOR }} endIcon={{ icon: PanelRight }} - on:click={() => aiChatManager.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)} + on:click={() => chatHost.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)} > Plan diff --git a/frontend/src/lib/components/copilot/chat/chatViewHost.ts b/frontend/src/lib/components/copilot/chat/chatViewHost.ts new file mode 100644 index 0000000000..9e3bdb44ac --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -0,0 +1,152 @@ +import { getContext, setContext } from 'svelte' +import type { AIMode, AIAutonomyMode } from './AIChatManager.svelte' +import { getAiChatManager } from './aiChatManagerContext' +import type { DisplayMessage, Tool } from './shared' +import type { ContextElement } from './context' +import type { AttachedImage } from './imageUtils' +import type { AttachedTextFile } from './textFileUtils' +import type { PasteAttachment } from './pasteTokens' +import type { AttachedFilesStore } from './files/attachedFiles.svelte' +import type { SessionArtifactsStore } from './artifacts/artifactsState.svelte' +import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter' +import type { FlowAIChatHelpers } from './flow/core' +import type { AppAIChatHelpers } from './app/core' +import type AIChatInput from './AIChatInput.svelte' + +export type ChatSendRequestOptions = { + instructions?: string + pastes?: PasteAttachment[] + images?: AttachedImage[] + files?: AttachedTextFile[] + /** Selected-context snapshot for this turn, in place of the live selection. Set + * whenever a send settles its context ahead of the turn. A host with no context + * of its own ignores it. */ + contextOverride?: ContextElement[] + /** Where `contextOverride` came from. 'pinned': chips picked for THIS message, so + * they are consumed from the live selection on send. 'replay': an edit or retry + * resending an older message's context, already consumed long ago. */ + contextOverrideOrigin?: 'pinned' | 'replay' +} + +/** + * What the chat view components (AIChatDisplay and everything it renders) need + * from whatever is driving the conversation. AIChatManager implements it for the + * copilot's own LLM loop; FlowChatViewHost implements it over a flow run's + * conversation so both chats render through the same components. + * + * Each affordance is gated by the field that answers for it — attachments by + * `supportsMessageAttachments`, the model button by `supportsModelSettings`, and so on — + * so a host turns on exactly what it can serve, and a new one is a matter of answering + * these fields rather than of being a copilot. `mode` is the exception, still read + * directly for chrome that only the copilot has. + */ +export interface ChatViewHost { + // Transcript + displayMessages: DisplayMessage[] + /** API-level messages. Only the count is read (context usage visibility). */ + messages: readonly unknown[] + contextTokens: number + /** The workspace a message's paths and jobs resolve against, which a fork session + * pins away from the navigated one. */ + readonly operatingWorkspace: string | undefined + loading: boolean + /** A turn this tab can neither follow nor stop, held by another tab on the same chat. */ + readonly runHeldElsewhere: boolean + loadingLabel: string | undefined + compacting: boolean + currentReply: string + currentReasoning: string + currentReasoningActive: boolean + readonly reasoningHiddenIndicatorLabel: string | undefined + readonly automaticScroll: boolean + enableAutomaticScroll: () => void + disableAutomaticScroll: () => void + + // Composer + instructions: string + readonly sendInFlight: boolean + /** Resolves to whether the draft was consumed as a turn. */ + sendRequest: (options?: ChatSendRequestOptions) => Promise + cancel: (reason?: string) => void + setAiChatInput: (aiChatInput: AIChatInput | null) => void + readonly queuedMessage: string + queuedContext: ContextElement[] | undefined + readonly queuedImages: AttachedImage[] + readonly queuedFiles: AttachedTextFile[] + queueMessage: ( + text: string, + images?: AttachedImage[], + context?: ContextElement[], + files?: AttachedTextFile[] + ) => void + dequeueMessage: () => void + setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void + clearComposerStaged: (key: string) => void + attachmentBytesExcluding: (selfKey: string) => number + + // Per-message actions + storedImages: (displayMessageIndex: number) => AttachedImage[] | undefined + retryRequest: (messageIndex: number) => void + restartGeneration: ( + displayMessageIndex: number, + newContent?: string, + pastes?: PasteAttachment[], + images?: AttachedImage[], + editedContext?: ContextElement[], + files?: AttachedTextFile[] + ) => void | Promise + handleUserQuestionAnswer: (toolId: string, choices: string[]) => boolean + handleToolConfirmation: (toolId: string, confirmed: boolean) => void + /** A tool is waiting on a run form the user is filling in. Escape belongs to that form + * then, not to the turn — see AIChatDisplay's window handler. */ + readonly hasPendingRunForm: boolean + isRunFormPending: (toolCallId: string) => boolean + + // Copilot-only surfaces. Left undefined/false by hosts that have no LLM loop + // of their own; the chrome they drive hides itself. + mode?: AIMode + isSessionChat: boolean + /** Model + reasoning picker. Off where the model is configured elsewhere. */ + supportsModelSettings: boolean + /** Click a user message to edit and resend it. Needs a host that can rewind + * its own transcript, which a host replaying a server-side run cannot. */ + supportsMessageEditing: boolean + /** The `+` menu's file entry and drag-and-drop onto the panel. */ + supportsMessageAttachments: boolean + /** The turn needs text: attachments alone cannot be sent. True where the consumer + * requires a message of its own — an AI agent step refuses a run with neither a + * `user_message` nor manual memory. */ + requiresMessageText: boolean + /** The `+` menu's folder entries, backed by `attachedFiles`. A linked folder is a + * live handle on the user's disk, so only a host reading files in the browser has one. */ + supportsLinkedFolders: boolean + /** `accept` for the file picker. */ + attachmentAccept: string + tools: Tool[] + autonomyMode: AIAutonomyMode + setAutonomyMode: (mode: AIAutonomyMode) => void + readonly autoAcceptEditsActive: boolean + readonly autoAcceptEditsAvailable: boolean + readonly autoAcceptToolConfirmationsAvailable: boolean + readonly planModeAvailable: boolean + attachedFiles: AttachedFilesStore + artifacts: SessionArtifactsStore + openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void + flowAiChatHelpers?: FlowAIChatHelpers + appAiChatHelpers?: AppAIChatHelpers +} + +const CHAT_VIEW_HOST_CONTEXT_KEY = 'chatViewHost' + +export function setChatViewHost(host: ChatViewHost) { + setContext(CHAT_VIEW_HOST_CONTEXT_KEY, host) +} + +/** + * Resolve the host driving the chat in this subtree. Falls back to the + * AIChatManager (scoped instance or app-wide singleton) so every existing + * copilot chat keeps working without setting anything. + */ +export function getChatViewHost(): ChatViewHost { + return getContext(CHAT_VIEW_HOST_CONTEXT_KEY) ?? getAiChatManager() +} diff --git a/frontend/src/lib/components/copilot/chat/composerBox.ts b/frontend/src/lib/components/copilot/chat/composerBox.ts new file mode 100644 index 0000000000..f85783d988 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/composerBox.ts @@ -0,0 +1,27 @@ +/** + * The composer's box, shared by both of AIChatInput's branches — the rich + * ContextTextarea and the plain textarea a host without @-context gets. + * + * Border and rounding live on the WRAPPER, never on the field, so the chip rows + * (context, files, images) sit inside the box above the text. The field's own + * @tailwindcss/forms border, ring and background are neutralised so only the + * wrapper reads as the input. + * + * The disabled treatment is on the wrapper for the same reason: `disabled` on the + * field alone leaves it looking exactly like a usable one, so the only cue that + * typing is refused is placeholder text the eye reads as an invitation. + */ + +const BOX_BASE = 'w-full scroll-pb-2 rounded-md border border-border-light transition-colors' + +export function composerBoxClass(disabled: boolean = false): string { + return `${BOX_BASE} ${ + disabled + ? 'bg-surface-disabled cursor-not-allowed' + : 'bg-surface-input focus-within:border-border-selected' + }` +} + +/** Applied to the field inside the box; without it the field draws a second border. */ +export const COMPOSER_FIELD_RESET = + '!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0 disabled:cursor-not-allowed disabled:placeholder:text-disabled' diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 73efd2e37e..bbcb76b30c 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -671,6 +671,14 @@ export type AssistantDisplayMessage = BaseDisplayMessage & { * would look like it is still streaming forever. */ streaming?: boolean + /** Flow step that produced this message, when the conversation is a flow run + * rather than a copilot turn. Rendered as a label above the content. */ + stepName?: string + /** The run behind this answer. Flow chats only: a copilot turn happens in the + * browser and has no job. */ + jobId?: string + /** When the message was stored, as the server reports it. */ + createdAt?: string } /** diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index 160af2bf27..dc4eb92b57 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -22,6 +22,9 @@ path: string hideSidebar?: boolean inputSchema?: Record + /** The flow's description, shown under the empty transcript's prompt. */ + description?: string + wideLayout?: boolean } let { @@ -29,7 +32,9 @@ deploymentInProgress = false, path, hideSidebar = false, - inputSchema = undefined + inputSchema = undefined, + description = undefined, + wideLayout = false }: Props = $props() const flowEditorContext = getContext('FlowEditorContext') @@ -90,13 +95,18 @@ {#if !hideSidebar} {/if} - + + {#key chat} + + {/key} {/if}
diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index 22fe037728..535be3ca83 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -1,79 +1,37 @@ @@ -164,82 +141,71 @@ {/if} -
- -
- {#if deploymentInProgress} - - {/if} - {#if chatState.loadingMessages && chatState.messages.length === 0} -
- -
- {:else if chatState.messages.length === 0} -
- -

Start a conversation

-

Send a message to run the flow and see the results

-
+{#snippet emptyHint()} +
+ {#if chatHost.state.loadingMessages} + {:else} -
- {#each chatState.messages as message (message.id)} - - {/each} - {#if busy} -
- - Processing... -
- {/if} -
- {/if} -
- - -
- {#if additionalInputsSchema} -
-
- - {#if hasMissingRequired} - - {/if} + +

Start a conversation

+

Send a message to run the flow and see the results

+ {#if !emptyString(description)} +
+
-
+ {/if} {/if} -
- { - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { - e.preventDefault() - handleSendMessage() - } - }} - showCancelButton={busy} - onCancel={() => chat.stop()} - sendTitle={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'} - /> -
+{/snippet} + +{#snippet footerSettings()} + {#if additionalInputsSchema} +
+ + {#if hasMissingRequired} + + {/if} +
+ {/if} +{/snippet} + + +
0} + class:min-h-64={chatHost.displayMessages.length === 0} +> + {}} + deletePastChat={() => {}} + saveAndClear={() => {}} + />
diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts new file mode 100644 index 0000000000..78118546ac --- /dev/null +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -0,0 +1,310 @@ +import type { Chat, ChatMessage, ChatState } from 'windmill-chat' +import type { + ChatSendRequestOptions, + ChatViewHost +} from '$lib/components/copilot/chat/chatViewHost' +import type { DisplayMessage } from '$lib/components/copilot/chat/shared' +import type { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte' +import { isPlanCardTool } from '$lib/components/copilot/chat/planMode' +import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte' +import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte' +import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils' +import type { AttachedTextFile } from '$lib/components/copilot/chat/textFileUtils' + +export type FlowChatViewHostOptions = { + /** The flow inputs sent next to `user_message` with every turn. */ + additionalInputs?: () => Record | undefined + /** The workspace the transcript's paths resolve against. */ + workspace?: () => string | undefined + /** Whether sending is refused right now (a deployment in progress, say). The composer + * is disabled on the same condition; this covers the sends the composer does not + * make itself: a queued message going out, a retry. */ + sendDisabled?: () => boolean +} + +function isBusy(status: ChatState['status']): boolean { + return status === 'submitted' || status === 'streaming' +} + +/** A tool's arguments or result as the card shows them: parsed where the string is JSON. */ +function parseToolPayload(raw: string | undefined): unknown { + if (raw === undefined || raw === '') return undefined + try { + return JSON.parse(raw) + } catch { + return raw + } +} + +/** + * Whether the turn the user message at `index` started failed: its last row before the + * next user message reports `success: false`. The last row, not any row: a tool call can + * fail and the agent still answer, and that turn completed. + */ +export function turnFailed(messages: readonly ChatMessage[], index: number): boolean { + let last: ChatMessage | undefined + for (let i = index + 1; i < messages.length; i++) { + const message = messages[i] + if (message.role === 'user') break + last = message + } + return last?.success === false +} + +/** Whether the latest turn failed, per `turnFailed`. False before any turn. */ +function lastTurnFailed(messages: readonly ChatMessage[]): boolean { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'user') return turnFailed(messages, i) + } + return false +} + +export function toDisplayMessages(messages: readonly ChatMessage[]): DisplayMessage[] { + let userIndex = 0 + return messages.map((message, i): DisplayMessage => { + switch (message.role) { + case 'user': + return { + role: 'user', + index: userIndex++, + content: message.content, + // Drives the shared Retry button. + error: turnFailed(messages, i) || undefined + } + case 'tool': { + const parameters = parseToolPayload(message.tool?.arguments) + const result = parseToolPayload(message.tool?.result) + const failed = message.success === false + return { + role: 'tool', + tool_call_id: message.id, + // The card's header is the row's text, which the server only words once the + // tool has returned; until then the row says what is running. + content: message.content || (message.tool ? `Running ${message.tool.name}` : ''), + // Withheld for the copilot's two plan-mode names: `toolName` is what makes + // ToolExecutionDisplay render a plan card, and an agent tool that happened to + // share one would silently become one. + toolName: isPlanCardTool(message.tool?.name) ? undefined : message.tool?.name, + parameters, + result, + showDetails: parameters !== undefined || result !== undefined, + error: failed ? message.content : undefined, + isLoading: message.pending && message.tool?.status === 'running' + } + } + default: + return { + role: 'assistant', + content: message.content, + // Only the message a turn is still writing: a finalized reasoning-only + // message must not look in progress. + streaming: message.pending || undefined, + reasoning: message.reasoning, + stepName: message.stepName, + jobId: message.jobId, + createdAt: message.createdAt + } + } + }) +} + +/** + * Renders a flow run's conversation, as the `windmill-chat` SDK keeps it, through the + * copilot's chat components. The turn is a flow job rather than an LLM call this host + * makes, so what it can offer is what the SDK's `Chat` can: a message in, an answer + * streamed back, Stop. Every copilot-only field is answered with "no" (see ChatViewHost). + * + * The host owns its subscription to the chat, so a panel swapping chats mounts a new one. + */ +export class FlowChatViewHost implements ChatViewHost { + #chat: Chat + #options: FlowChatViewHostOptions + #state = $state.raw() as ChatState + #unsubscribe: () => void + + constructor(chat: Chat, options: FlowChatViewHostOptions = {}) { + this.#chat = chat + this.#options = options + this.#state = chat.getState() + this.#unsubscribe = chat.subscribe((state) => this.#onState(state)) + } + + #disposed = false + /** Stops following the chat, and drops what was queued: a flush still waiting on the + * turn's release would otherwise start a run from a panel that is gone. The chat itself + * is the caller's to destroy. */ + dispose() { + this.#disposed = true + this.#queued = '' + this.#unsubscribe() + } + + /** The latest `ChatState`, for what the interface reads beyond the seam (paging, loading). */ + get state(): ChatState { + return this.#state + } + + #onState(state: ChatState) { + const previous = this.#state + this.#state = state + if (previous.conversationId !== state.conversationId) { + // A conversation opens at its end, whatever the reader was doing in the last one. + this.#automaticScroll = true + // The queue was typed into the conversation that just went away; a message sent + // after the switch would ride out of the wrong one, so it goes back to the composer. + this.dequeueMessage() + return + } + if (isBusy(previous.status) && !isBusy(state.status)) { + // The turn settled. What was typed during it goes out once the turn is released, + // not now: the chat publishes `idle` from inside its own `sendMessage`, which still + // counts the turn as open until it returns, and a send made before that would be + // refused as a second turn. After a failure it goes back to the composer instead, + // where the reader would rather look at the error than pile on. A failed flow + // settles as `idle` too, with its error as the answer, so the messages decide. + const succeeded = state.status === 'idle' && !lastTurnFailed(state.messages) + if (succeeded) void this.#turnDone.then(this.flushQueuedMessage) + else this.dequeueMessage() + } + } + + // Transcript + displayMessages = $derived.by(() => toDisplayMessages(this.#state.messages)) + get messages(): readonly unknown[] { + return this.#state.messages + } + contextTokens = 0 + get operatingWorkspace(): string | undefined { + return this.#options.workspace?.() + } + get loading(): boolean { + return isBusy(this.#state.status) + } + runHeldElsewhere = false + loadingLabel = undefined + compacting = false + // The answer streams into the message list itself, so the live lanes stay empty. + currentReply = '' + currentReasoning = '' + currentReasoningActive = false + reasoningHiddenIndicatorLabel = undefined + #automaticScroll = $state(true) + get automaticScroll(): boolean { + return this.#automaticScroll + } + enableAutomaticScroll = () => { + this.#automaticScroll = true + } + disableAutomaticScroll = () => { + this.#automaticScroll = false + } + + // Composer + instructions = '' + // The user message lands in the transcript before `sendMessage` awaits anything. + sendInFlight = false + sendRequest = async (options: ChatSendRequestOptions = {}): Promise => { + const text = options.instructions?.trim() ?? '' + if (!text) return false + if (this.loading) { + this.queueMessage(text) + return true + } + if (this.#options.sendDisabled?.()) { + // Refused, not dropped: the text waits in the composer for sending to reopen. + this.#aiChatInput?.prependText(text) + return false + } + this.#automaticScroll = true + // A run that fails is reported through the chat's `onError` and as a failed message; + // the promise itself only rejects when the chat refuses the turn outright, and the + // text is then handed back rather than dropped. + const turn = this.#chat + .sendMessage(text, { inputs: this.#options.additionalInputs?.() }) + .catch(() => this.#aiChatInput?.prependText(text)) + this.#turnDone = turn + await turn + return true + } + /** Settles when the chat has released the last turn this host started. */ + #turnDone: Promise = Promise.resolve() + cancel = () => { + // Stop means stop: what was typed during the run goes back to the composer rather + // than waiting there to go out after some later turn settles. + this.dequeueMessage() + void this.#chat.stop() + } + // Typed off the interface: a Svelte component's own type resolves differently + // across import specifiers, and the two would then not be assignable. + #aiChatInput: Parameters[0] = null + setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) => { + this.#aiChatInput = aiChatInput + } + + // One message typed while the turn runs, sent whole once it settles. Enter again + // appends a line rather than replacing what waits. + #queued = $state('') + get queuedMessage(): string { + return this.#queued + } + queuedContext = undefined + queuedImages: AttachedImage[] = [] + queuedFiles: AttachedTextFile[] = [] + queueMessage = (text: string) => { + const trimmed = text.trim() + if (!trimmed) return + this.#queued = this.#queued ? `${this.#queued}\n${trimmed}` : trimmed + } + /** Put the queued draft back in the composer. */ + dequeueMessage = () => { + const text = this.#queued + if (!text) return + this.#queued = '' + this.#aiChatInput?.prependText(text) + } + flushQueuedMessage = () => { + const text = this.#queued + if (!text || this.#disposed) return + this.#queued = '' + void this.sendRequest({ instructions: text }) + } + setComposerStaged = () => {} + clearComposerStaged = () => {} + attachmentBytesExcluding = () => 0 + + // Per-message actions + storedImages = () => undefined + /** Send the user message at this transcript position again. */ + retryRequest = (messageIndex: number) => { + const message = this.#state.messages[messageIndex] + if (!message || message.role !== 'user' || this.loading) return + void this.sendRequest({ instructions: message.content }) + } + restartGeneration = () => {} + handleUserQuestionAnswer = () => false + handleToolConfirmation = () => {} + hasPendingRunForm = false + isRunFormPending = () => false + + // Copilot-only surfaces + mode = undefined + isSessionChat = false + supportsModelSettings = false + supportsMessageEditing = false + supportsMessageAttachments = false + // An AI agent step refuses a run with no `user_message`. + requiresMessageText = true + supportsLinkedFolders = false + attachmentAccept = '' + tools = [] + // The enum's value, written out so this module never imports the copilot manager at + // runtime: its unit test would otherwise load the manager and the editor it pulls in. + autonomyMode = 'default' as AIAutonomyMode.DEFAULT + setAutonomyMode = () => {} + autoAcceptEditsActive = false + autoAcceptEditsAvailable = false + autoAcceptToolConfirmationsAvailable = false + planModeAvailable = false + attachedFiles = new AttachedFilesStore() + artifacts = new SessionArtifactsStore() +} diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts new file mode 100644 index 0000000000..a7e953ecf5 --- /dev/null +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Chat, ChatMessage, ChatState } from 'windmill-chat' +import { FlowChatViewHost, toDisplayMessages } from './flowChatViewHost.svelte' + +function message(partial: Partial & Pick): ChatMessage { + return { + id: partial.id ?? `${partial.role}-${Math.random()}`, + content: '', + success: true, + createdAt: '2026-09-16T10:00:00Z', + pending: false, + ...partial + } +} + +function idleState(partial: Partial = {}): ChatState { + return { + conversationId: 'c1', + messages: [], + status: 'idle', + error: undefined, + conversations: [], + history: 'server', + loadingMessages: false, + hasMoreMessages: false, + ...partial + } +} + +/** A `Chat` whose state the test drives by hand. */ +function fakeChat(initial: ChatState = idleState()) { + let state = initial + const listeners = new Set<(s: ChatState) => void>() + const chat = { + getState: () => state, + subscribe: (listener: (s: ChatState) => void) => { + listeners.add(listener) + listener(state) + return () => listeners.delete(listener) + }, + sendMessage: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + newConversation: vi.fn(), + selectConversation: vi.fn(async () => {}), + loadConversations: vi.fn(async () => []), + deleteConversation: vi.fn(async () => {}), + loadOlderMessages: vi.fn(async () => {}), + destroy: vi.fn() + } satisfies Chat + const set = (patch: Partial) => { + state = { ...state, ...patch } + for (const listener of listeners) listener(state) + } + return { chat, set } +} + +describe('toDisplayMessages', () => { + it('maps user, assistant and tool rows, marking the user message of a failed turn', () => { + const rows = [ + message({ role: 'user', content: 'hi' }), + message({ + role: 'assistant', + content: 'hello', + reasoning: 'thinking', + stepName: 'agent', + jobId: 'job-1', + createdAt: '2026-09-16T10:00:01Z' + }), + message({ role: 'user', content: 'again' }), + // A tool still running has no text yet: the server words the row on its return. + message({ + role: 'tool', + id: 'tool-row', + pending: true, + tool: { name: 'search', status: 'running', arguments: '{"q":"x"}' } + }), + message({ role: 'assistant', content: 'boom', success: false }) + ] + const display = toDisplayMessages(rows) + expect(display[0]).toEqual({ role: 'user', index: 0, content: 'hi', error: undefined }) + expect(display[1]).toMatchObject({ + role: 'assistant', + content: 'hello', + reasoning: 'thinking', + stepName: 'agent', + jobId: 'job-1', + createdAt: '2026-09-16T10:00:01Z', + streaming: undefined + }) + expect(display[2]).toEqual({ role: 'user', index: 1, content: 'again', error: true }) + expect(display[3]).toMatchObject({ + role: 'tool', + tool_call_id: 'tool-row', + content: 'Running search', + toolName: 'search', + parameters: { q: 'x' }, + showDetails: true, + isLoading: true, + error: undefined + }) + }) + + it('does not flag a turn whose tool failed but whose agent still answered', () => { + const display = toDisplayMessages([ + message({ role: 'user', content: 'try' }), + message({ + role: 'tool', + content: 'Error executing search', + success: false, + tool: { name: 'search', status: 'error' } + }), + message({ role: 'assistant', content: 'search is down, here is what I know' }) + ]) + expect(display[0]).toMatchObject({ role: 'user', error: undefined }) + }) + + it('flags the streaming assistant message and a failed tool', () => { + const display = toDisplayMessages([ + message({ role: 'assistant', content: 'partial', pending: true }), + message({ + role: 'tool', + content: 'Error executing search', + success: false, + tool: { name: 'search', status: 'error' } + }) + ]) + expect(display[0]).toMatchObject({ role: 'assistant', streaming: true }) + expect(display[1]).toMatchObject({ + role: 'tool', + error: 'Error executing search', + showDetails: false, + isLoading: false + }) + }) +}) + +describe('FlowChatViewHost', () => { + it('sends the text with the additional inputs and reports loading from the status', async () => { + const { chat, set } = fakeChat() + const host = new FlowChatViewHost(chat, { additionalInputs: () => ({ tone: 'brief' }) }) + expect(host.loading).toBe(false) + expect(await host.sendRequest({ instructions: ' hello ' })).toBe(true) + expect(chat.sendMessage).toHaveBeenCalledWith('hello', { inputs: { tone: 'brief' } }) + expect(await host.sendRequest({ instructions: ' ' })).toBe(false) + set({ status: 'streaming' }) + expect(host.loading).toBe(true) + set({ status: 'idle' }) + expect(host.loading).toBe(false) + host.dispose() + }) + + it('queues a message typed during a turn and sends it once the turn is released', async () => { + const { chat, set } = fakeChat() + // The chat publishes `idle` from inside `sendMessage`, before that call returns and + // releases the turn; a flush in between is refused as a second turn. + let releaseTurn = () => {} + chat.sendMessage.mockImplementationOnce( + () => new Promise((resolve) => (releaseTurn = resolve)) + ) + const host = new FlowChatViewHost(chat) + void host.sendRequest({ instructions: 'start' }) + set({ status: 'streaming' }) + host.queueMessage('first') + host.queueMessage('second') + expect(host.queuedMessage).toBe('first\nsecond') + set({ status: 'idle' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(chat.sendMessage).toHaveBeenCalledTimes(1) + releaseTurn() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(host.queuedMessage).toBe('') + expect(chat.sendMessage).toHaveBeenLastCalledWith('first\nsecond', { inputs: undefined }) + host.dispose() + }) + + it('hands the queue back when a failed flow settles as idle', () => { + const { chat, set } = fakeChat( + idleState({ status: 'streaming', messages: [message({ role: 'user', content: 'go' })] }) + ) + const host = new FlowChatViewHost(chat) + const prependText = vi.fn() + host.setAiChatInput({ prependText } as any) + host.queueMessage('later') + // A failed flow is answered with its error and the status still returns to idle. + set({ + status: 'idle', + messages: [ + message({ role: 'user', content: 'go' }), + message({ role: 'assistant', content: 'boom', success: false }) + ] + }) + expect(prependText).toHaveBeenCalledWith('later') + expect(chat.sendMessage).not.toHaveBeenCalled() + host.dispose() + }) + + it('hands the queue back instead of sending while sending is disabled', async () => { + const { chat, set } = fakeChat(idleState({ status: 'streaming' })) + let deploying = false + const host = new FlowChatViewHost(chat, { sendDisabled: () => deploying }) + const prependText = vi.fn() + host.setAiChatInput({ prependText } as any) + host.queueMessage('after deploy') + // A deployment starts while the turn is still running; the composer is disabled. + deploying = true + set({ status: 'idle' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(chat.sendMessage).not.toHaveBeenCalled() + expect(prependText).toHaveBeenCalledWith('after deploy') + expect(host.queuedMessage).toBe('') + host.dispose() + }) + + it('drops a flush still waiting on the turn once disposed', async () => { + const { chat, set } = fakeChat() + let releaseTurn = () => {} + chat.sendMessage.mockImplementationOnce( + () => new Promise((resolve) => (releaseTurn = resolve)) + ) + const host = new FlowChatViewHost(chat) + void host.sendRequest({ instructions: 'start' }) + set({ status: 'streaming' }) + host.queueMessage('never') + set({ status: 'idle' }) + host.dispose() + releaseTurn() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(chat.sendMessage).toHaveBeenCalledTimes(1) + }) + + it('hands the text back when the chat refuses the turn', async () => { + const { chat } = fakeChat() + chat.sendMessage.mockRejectedValueOnce(new Error('a message is already being answered')) + const host = new FlowChatViewHost(chat) + const prependText = vi.fn() + host.setAiChatInput({ prependText } as any) + await host.sendRequest({ instructions: 'kept' }) + expect(prependText).toHaveBeenCalledWith('kept') + host.dispose() + }) + + it('hands the queue back to the composer on Stop and on a failed turn', async () => { + const { chat, set } = fakeChat(idleState({ status: 'streaming' })) + const host = new FlowChatViewHost(chat) + const prependText = vi.fn() + host.setAiChatInput({ prependText } as any) + host.queueMessage('later') + host.cancel() + expect(chat.stop).toHaveBeenCalled() + expect(prependText).toHaveBeenCalledWith('later') + expect(host.queuedMessage).toBe('') + + host.queueMessage('after error') + set({ status: 'error' }) + expect(prependText).toHaveBeenLastCalledWith('after error') + expect(chat.sendMessage).not.toHaveBeenCalled() + host.dispose() + }) + + it('stops following the chat once disposed', () => { + const { chat, set } = fakeChat() + const host = new FlowChatViewHost(chat) + host.dispose() + set({ status: 'streaming' }) + expect(host.loading).toBe(false) + }) +}) diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index a1384579af..5cd5b653da 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -703,7 +703,9 @@ onRunFlow={runFlowForChat} {deploymentInProgress} path={flow?.path ?? ''} + description={flow?.description} inputSchema={flow?.schema} + wideLayout /> {:else} {@const hasSchema = From 23c24a9688d4c8c462f53221334d538280f16bca Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 17 Sep 2026 09:27:12 +0200 Subject: [PATCH 09/22] feat: collect flow conversations and agent memory once their last message goes (#11178) * feat: collect flow conversations and agent memory when their last message goes * fix: lock the conversation lookup so a new turn orders against its cleanup * fix: let concurrent turns recreate a collected conversation without conflicting --- ...9a1fabeaa167818206a25abfe31d5582f942a.json | 15 ++ ...7cb3b31ee6b69abae79a214dddba0dee4425c.json | 15 -- ...0054b753cfbeb5dad1e8120fd5e8a672d50ef.json | 23 ++ ...3659915d9fcb3d58f16532aaffe06c44ec976.json | 23 ++ ...3ca33b3f82ca38289dfa4a69aa95041027d57.json | 59 +++++ ...9672f8c98d2579fc72c07752361cc5fd683dc.json | 15 ++ ...8e44eb1c0062a8241dc8fe0ed3dd95b65f89a.json | 14 + ...df90dad5246681bde6caaa633b85a5e8b2352.json | 22 ++ ...741a51ca0ce66bf1c674097705cf2e1b72eff.json | 14 + ...2893147b4e5ff078531de36147d908132d636.json | 14 - ...fee4a74f61b061e5025ecb75882208b3ba8f.json} | 4 +- ...7a30d71a5689efe72daf58bd1e8f6cf90c410.json | 22 ++ backend/tests/v2_job_delete_orphans.rs | 248 +++++++++++++++++- backend/windmill-api-jobs/src/execution.rs | 14 +- backend/windmill-api-jobs/src/jobs_export.rs | 60 ++++- backend/windmill-api/src/jobs.rs | 1 + .../windmill-common/src/flow_conversations.rs | 55 ++-- backend/windmill-common/src/jobs.rs | 55 +++- 18 files changed, 614 insertions(+), 59 deletions(-) create mode 100644 backend/.sqlx/query-07a005f0f9e80a156cd2a5a0ae39a1fabeaa167818206a25abfe31d5582f942a.json delete mode 100644 backend/.sqlx/query-462d2b2822b185a6f51fafcfa957cb3b31ee6b69abae79a214dddba0dee4425c.json create mode 100644 backend/.sqlx/query-4f52bf546579f26a1d22c239b8b0054b753cfbeb5dad1e8120fd5e8a672d50ef.json create mode 100644 backend/.sqlx/query-69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976.json create mode 100644 backend/.sqlx/query-6f32c1feed096ff706ae359ad6a3ca33b3f82ca38289dfa4a69aa95041027d57.json create mode 100644 backend/.sqlx/query-89ea81b765550cf665e30533efc9672f8c98d2579fc72c07752361cc5fd683dc.json create mode 100644 backend/.sqlx/query-90b910da8d00a7c7bcf29c167e38e44eb1c0062a8241dc8fe0ed3dd95b65f89a.json create mode 100644 backend/.sqlx/query-967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352.json create mode 100644 backend/.sqlx/query-a1b23f3e62c6433d95cdac58215741a51ca0ce66bf1c674097705cf2e1b72eff.json delete mode 100644 backend/.sqlx/query-bfdd60b42e32bd81e2d20b327462893147b4e5ff078531de36147d908132d636.json rename backend/.sqlx/{query-6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe.json => query-c1e3ed3ecc3bcb98f60ba8196d33fee4a74f61b061e5025ecb75882208b3ba8f.json} (84%) create mode 100644 backend/.sqlx/query-ec295b3890a0018475ec0a3774c7a30d71a5689efe72daf58bd1e8f6cf90c410.json diff --git a/backend/.sqlx/query-07a005f0f9e80a156cd2a5a0ae39a1fabeaa167818206a25abfe31d5582f942a.json b/backend/.sqlx/query-07a005f0f9e80a156cd2a5a0ae39a1fabeaa167818206a25abfe31d5582f942a.json new file mode 100644 index 0000000000..635ec2b94d --- /dev/null +++ b/backend/.sqlx/query-07a005f0f9e80a156cd2a5a0ae39a1fabeaa167818206a25abfe31d5582f942a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM flow_conversation c\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [] + }, + "hash": "07a005f0f9e80a156cd2a5a0ae39a1fabeaa167818206a25abfe31d5582f942a" +} diff --git a/backend/.sqlx/query-462d2b2822b185a6f51fafcfa957cb3b31ee6b69abae79a214dddba0dee4425c.json b/backend/.sqlx/query-462d2b2822b185a6f51fafcfa957cb3b31ee6b69abae79a214dddba0dee4425c.json deleted file mode 100644 index 0c4d90b073..0000000000 --- a/backend/.sqlx/query-462d2b2822b185a6f51fafcfa957cb3b31ee6b69abae79a214dddba0dee4425c.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "UuidArray" - ] - }, - "nullable": [] - }, - "hash": "462d2b2822b185a6f51fafcfa957cb3b31ee6b69abae79a214dddba0dee4425c" -} diff --git a/backend/.sqlx/query-4f52bf546579f26a1d22c239b8b0054b753cfbeb5dad1e8120fd5e8a672d50ef.json b/backend/.sqlx/query-4f52bf546579f26a1d22c239b8b0054b753cfbeb5dad1e8120fd5e8a672d50ef.json new file mode 100644 index 0000000000..a5695f75fd --- /dev/null +++ b/backend/.sqlx/query-4f52bf546579f26a1d22c239b8b0054b753cfbeb5dad1e8120fd5e8a672d50ef.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM flow_conversation WHERE id = ANY($1) AND workspace_id = $2 ORDER BY id FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4f52bf546579f26a1d22c239b8b0054b753cfbeb5dad1e8120fd5e8a672d50ef" +} diff --git a/backend/.sqlx/query-69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976.json b/backend/.sqlx/query-69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976.json new file mode 100644 index 0000000000..52105ca605 --- /dev/null +++ b/backend/.sqlx/query-69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)\n RETURNING m.conversation_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "conversation_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "UuidArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976" +} diff --git a/backend/.sqlx/query-6f32c1feed096ff706ae359ad6a3ca33b3f82ca38289dfa4a69aa95041027d57.json b/backend/.sqlx/query-6f32c1feed096ff706ae359ad6a3ca33b3f82ca38289dfa4a69aa95041027d57.json new file mode 100644 index 0000000000..0e92e3aa99 --- /dev/null +++ b/backend/.sqlx/query-6f32c1feed096ff706ae359ad6a3ca33b3f82ca38289dfa4a69aa95041027d57.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2\n FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "flow_path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "6f32c1feed096ff706ae359ad6a3ca33b3f82ca38289dfa4a69aa95041027d57" +} diff --git a/backend/.sqlx/query-89ea81b765550cf665e30533efc9672f8c98d2579fc72c07752361cc5fd683dc.json b/backend/.sqlx/query-89ea81b765550cf665e30533efc9672f8c98d2579fc72c07752361cc5fd683dc.json new file mode 100644 index 0000000000..7a7110030c --- /dev/null +++ b/backend/.sqlx/query-89ea81b765550cf665e30533efc9672f8c98d2579fc72c07752361cc5fd683dc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM ai_agent_memory a\n USING flow_conversation c\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND a.conversation_id = c.id\n AND a.workspace_id = c.workspace_id\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [] + }, + "hash": "89ea81b765550cf665e30533efc9672f8c98d2579fc72c07752361cc5fd683dc" +} diff --git a/backend/.sqlx/query-90b910da8d00a7c7bcf29c167e38e44eb1c0062a8241dc8fe0ed3dd95b65f89a.json b/backend/.sqlx/query-90b910da8d00a7c7bcf29c167e38e44eb1c0062a8241dc8fe0ed3dd95b65f89a.json new file mode 100644 index 0000000000..1555f3683d --- /dev/null +++ b/backend/.sqlx/query-90b910da8d00a7c7bcf29c167e38e44eb1c0062a8241dc8fe0ed3dd95b65f89a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM flow_conversation c\n WHERE c.id = ANY($1)\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "90b910da8d00a7c7bcf29c167e38e44eb1c0062a8241dc8fe0ed3dd95b65f89a" +} diff --git a/backend/.sqlx/query-967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352.json b/backend/.sqlx/query-967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352.json new file mode 100644 index 0000000000..5161f716ff --- /dev/null +++ b/backend/.sqlx/query-967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "conversation_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352" +} diff --git a/backend/.sqlx/query-a1b23f3e62c6433d95cdac58215741a51ca0ce66bf1c674097705cf2e1b72eff.json b/backend/.sqlx/query-a1b23f3e62c6433d95cdac58215741a51ca0ce66bf1c674097705cf2e1b72eff.json new file mode 100644 index 0000000000..d8fdd5140b --- /dev/null +++ b/backend/.sqlx/query-a1b23f3e62c6433d95cdac58215741a51ca0ce66bf1c674097705cf2e1b72eff.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM ai_agent_memory a\n USING flow_conversation c\n WHERE c.id = ANY($1)\n AND a.conversation_id = c.id\n AND a.workspace_id = c.workspace_id\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "a1b23f3e62c6433d95cdac58215741a51ca0ce66bf1c674097705cf2e1b72eff" +} diff --git a/backend/.sqlx/query-bfdd60b42e32bd81e2d20b327462893147b4e5ff078531de36147d908132d636.json b/backend/.sqlx/query-bfdd60b42e32bd81e2d20b327462893147b4e5ff078531de36147d908132d636.json deleted file mode 100644 index f9fbc7a58b..0000000000 --- a/backend/.sqlx/query-bfdd60b42e32bd81e2d20b327462893147b4e5ff078531de36147d908132d636.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM flow_conversation_message WHERE job_id = ANY($1)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray" - ] - }, - "nullable": [] - }, - "hash": "bfdd60b42e32bd81e2d20b327462893147b4e5ff078531de36147d908132d636" -} diff --git a/backend/.sqlx/query-6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe.json b/backend/.sqlx/query-c1e3ed3ecc3bcb98f60ba8196d33fee4a74f61b061e5025ecb75882208b3ba8f.json similarity index 84% rename from backend/.sqlx/query-6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe.json rename to backend/.sqlx/query-c1e3ed3ecc3bcb98f60ba8196d33fee4a74f61b061e5025ecb75882208b3ba8f.json index d7f5fc45d4..50ba9d2897 100644 --- a/backend/.sqlx/query-6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe.json +++ b/backend/.sqlx/query-c1e3ed3ecc3bcb98f60ba8196d33fee4a74f61b061e5025ecb75882208b3ba8f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by", + "query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (id) DO NOTHING\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by", "describe": { "columns": [ { @@ -58,5 +58,5 @@ false ] }, - "hash": "6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe" + "hash": "c1e3ed3ecc3bcb98f60ba8196d33fee4a74f61b061e5025ecb75882208b3ba8f" } diff --git a/backend/.sqlx/query-ec295b3890a0018475ec0a3774c7a30d71a5689efe72daf58bd1e8f6cf90c410.json b/backend/.sqlx/query-ec295b3890a0018475ec0a3774c7a30d71a5689efe72daf58bd1e8f6cf90c410.json new file mode 100644 index 0000000000..1e35bd15ac --- /dev/null +++ b/backend/.sqlx/query-ec295b3890a0018475ec0a3774c7a30d71a5689efe72daf58bd1e8f6cf90c410.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM flow_conversation WHERE id = ANY($1) ORDER BY id FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ec295b3890a0018475ec0a3774c7a30d71a5689efe72daf58bd1e8f6cf90c410" +} diff --git a/backend/tests/v2_job_delete_orphans.rs b/backend/tests/v2_job_delete_orphans.rs index 95cd1673b6..ded760b8ff 100644 --- a/backend/tests/v2_job_delete_orphans.rs +++ b/backend/tests/v2_job_delete_orphans.rs @@ -37,7 +37,7 @@ async fn seed_side_rows(db: &Pool, ws: &str, job_id: Uuid) -> anyhow:: .bind(ws) .execute(db) .await?; - // created_seq is assigned by a trigger; inserting a value is rejected. + // created_seq is an identity column; supplying a value is rejected. sqlx::query( "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id) VALUES ($1, 'assistant', 'hi', $2)", @@ -121,6 +121,184 @@ async fn test_delete_jobs_removes_side_rows(db: Pool) -> anyhow::Resul Ok(()) } +/// (conversation rows, agent-memory rows) for one conversation. +async fn conversation_and_memory_counts( + db: &Pool, + conversation_id: Uuid, +) -> anyhow::Result<(i64, i64)> { + Ok(( + count( + db, + "SELECT count(*) FROM flow_conversation WHERE id = $1", + conversation_id, + ) + .await?, + count( + db, + "SELECT count(*) FROM ai_agent_memory WHERE conversation_id = $1", + conversation_id, + ) + .await?, + )) +} + +/// A conversation outlives the jobs behind its messages until the last one goes: only then +/// are the row and the agent's memory for it left with nothing, and only then are they +/// deleted. Both halves matter — the surviving half is what a single data-modifying CTE +/// would break, since its emptiness check would read the snapshot from before the delete. +#[sqlx::test(fixtures("base"))] +async fn test_delete_jobs_removes_a_conversation_once_its_last_message_goes( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let first_job = Uuid::new_v4(); + let second_job = Uuid::new_v4(); + insert_job(&db, WS, first_job).await?; + insert_job(&db, WS, second_job).await?; + + let conv_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by) + VALUES ($1, $2, 'f/flow', 'test-user')", + ) + .bind(conv_id) + .bind(WS) + .execute(&db) + .await?; + for job_id in [first_job, second_job] { + sqlx::query( + "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id) + VALUES ($1, 'assistant', 'hi', $2)", + ) + .bind(conv_id) + .bind(job_id) + .execute(&db) + .await?; + } + sqlx::query( + "INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages) + VALUES ($1, $2, 'a', '[]'::jsonb)", + ) + .bind(WS) + .bind(conv_id) + .execute(&db) + .await?; + + let mut conn = db.acquire().await?; + windmill_common::jobs::delete_jobs(&mut conn, &[first_job]).await?; + drop(conn); + assert_eq!( + conversation_and_memory_counts(&db, conv_id).await?, + (1, 1), + "a conversation with a message left must survive, memory included" + ); + + let mut conn = db.acquire().await?; + windmill_common::jobs::delete_jobs(&mut conn, &[second_job]).await?; + drop(conn); + assert_eq!( + conversation_and_memory_counts(&db, conv_id).await?, + (0, 0), + "the last message going should take the conversation and its memory" + ); + Ok(()) +} + +/// Turns that start while retention is collecting their conversation must land, not fail: +/// the conversation lookup locks the row, so each turn waits for the collector's commit, +/// finds the conversation gone, and creates it again — the first insert wins and the other +/// reads its row. Without the lock a turn's message insert is what waits, on the parent +/// row's key lock, and fails its FK check afterwards. +#[sqlx::test(fixtures("base"))] +async fn test_new_turns_wait_for_conversation_cleanup_and_recreate( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let old_job = Uuid::new_v4(); + let new_jobs = [Uuid::new_v4(), Uuid::new_v4()]; + insert_job(&db, WS, old_job).await?; + for job in new_jobs { + insert_job(&db, WS, job).await?; + } + + let conv_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by) + VALUES ($1, $2, 'f/flow', 'test-user')", + ) + .bind(conv_id) + .bind(WS) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id) + VALUES ($1, 'user', 'hi', $2)", + ) + .bind(conv_id) + .bind(old_job) + .execute(&db) + .await?; + + // The collector holds the conversation row locked and deleted, uncommitted. + let mut cleanup = db.begin().await?; + windmill_common::jobs::delete_jobs(&mut *cleanup, &[old_job]).await?; + + let turns: Vec<_> = new_jobs + .into_iter() + .map(|new_job| { + let db = db.clone(); + tokio::spawn(async move { + let mut tx = db.begin().await?; + windmill_common::flow_conversations::get_or_create_conversation_with_id( + &mut tx, + WS, + "f/flow", + "test-user", + "hi again", + conv_id, + ) + .await?; + windmill_common::flow_conversations::add_message_to_conversation_tx( + &mut tx, + conv_id, + Some(new_job), + "hi again", + windmill_common::flow_conversations::MessageType::User, + None, + true, + ) + .await?; + tx.commit().await?; + anyhow::Ok(()) + }) + }) + .collect(); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + cleanup.commit().await?; + for turn in turns { + turn.await??; + } + + assert_eq!( + conversation_and_memory_counts(&db, conv_id).await?.0, + 1, + "the turns must have created the conversation again, once" + ); + assert_eq!( + count( + &db, + "SELECT count(*) FROM flow_conversation_message WHERE conversation_id = $1", + conv_id, + ) + .await?, + 2, + "both turns' messages should be there" + ); + Ok(()) +} + #[sqlx::test(fixtures("base"))] async fn test_clear_schedule_removes_side_rows(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; @@ -192,6 +370,74 @@ async fn test_workspace_delete_removes_side_rows(db: Pool) -> anyhow:: Ok(()) } +/// The purge endpoint carries its own copy of the emptied-conversation rule, so it gets the +/// same guard: the conversation and its memory go with the last message, and not before. +#[sqlx::test(fixtures("base"))] +async fn test_jobs_export_delete_removes_a_conversation_once_its_last_message_goes( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let first_job = Uuid::new_v4(); + let second_job = Uuid::new_v4(); + insert_job(&db, WS, first_job).await?; + insert_job(&db, WS, second_job).await?; + + let conv_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by) + VALUES ($1, $2, 'f/flow', 'test-user')", + ) + .bind(conv_id) + .bind(WS) + .execute(&db) + .await?; + for job_id in [first_job, second_job] { + sqlx::query( + "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id) + VALUES ($1, 'assistant', 'hi', $2)", + ) + .bind(conv_id) + .bind(job_id) + .execute(&db) + .await?; + } + sqlx::query( + "INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages) + VALUES ($1, $2, 'a', '[]'::jsonb)", + ) + .bind(WS) + .bind(conv_id) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let purge = |job_id: Uuid| async move { + reqwest::Client::new() + .post(format!("http://localhost:{port}/api/w/{WS}/jobs/delete")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&[job_id]) + .send() + .await + }; + + assert!(purge(first_job).await?.status().is_success()); + assert_eq!( + conversation_and_memory_counts(&db, conv_id).await?, + (1, 1), + "a conversation with a message left must survive the purge endpoint too" + ); + + assert!(purge(second_job).await?.status().is_success()); + assert_eq!( + conversation_and_memory_counts(&db, conv_id).await?, + (0, 0), + "the last message going should take the conversation and its memory" + ); + Ok(()) +} + /// The `/jobs/delete` purge endpoint must scope every side-table delete to the path /// workspace. A `test-workspace` admin passing a job id from another workspace must not be /// able to delete that workspace's job or side rows (the side tables no longer cascade, so diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 310e44ae1f..c15068583d 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -668,10 +668,16 @@ pub async fn handle_chat_conversation_messages( flow_path: &str, run_query: &RunJobQuery, user_message_raw: Option<&Box>, + job_id: Uuid, ) -> error::Result<()> { + // Names the query parameter rather than the field: it is not a flow argument, and + // supplying it as one is the first thing tried on reading `memory_id is required`. let memory_id = run_query.memory_id.ok_or_else(|| { windmill_common::error::Error::BadRequest( - "memory_id is required for chat-enabled flows".to_string(), + "memory_id is required for chat-enabled flows. Pass it as the `memory_id` query \ + parameter, not as a flow argument: it names the conversation the turn belongs to, \ + so a fresh UUID starts one and reusing a UUID continues it." + .to_string(), ) })?; @@ -698,10 +704,13 @@ pub async fn handle_chat_conversation_messages( ) .await?; + // The run this message started. Its args are the only record of what the message + // carried besides its text — attachments and every other flow input — and nothing + // written later points at them: an assistant row holds the AI agent step's job. add_message_to_conversation_tx( tx, memory_id, - None, + Some(job_id), &user_message, MessageType::User, None, @@ -826,6 +835,7 @@ pub async fn run_flow<'c>( &flow_path.to_string(), &run_query, args.args.get("user_message"), + uuid, ) .await?; } diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs index 05f5a51bbc..6389134031 100644 --- a/backend/windmill-api-jobs/src/jobs_export.rs +++ b/backend/windmill-api-jobs/src/jobs_export.rs @@ -692,16 +692,64 @@ pub async fn delete_jobs( .await? .rows_affected(); - let conversation_message_deleted = sqlx::query!( + // One row per message deleted, so the conversation of a chat losing several appears + // several times: the count is taken before the dedup below. + let mut conversation_ids: Vec = sqlx::query_scalar!( "DELETE FROM flow_conversation_message m USING flow_conversation c - WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)", + WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2) + RETURNING m.conversation_id", &w_id, &job_ids ) - .execute(&mut *tx) - .await? - .rows_affected(); + .fetch_all(&mut *tx) + .await?; + let conversation_message_deleted = conversation_ids.len() as u64; + + // Same rule, lock and statement order as retention (windmill_common::jobs::delete_jobs, + // which says why): a conversation with no messages left goes, and its memory with it. + conversation_ids.sort_unstable(); + conversation_ids.dedup(); + let mut memory_deleted = 0; + let mut conversation_deleted = 0; + if !conversation_ids.is_empty() { + sqlx::query_scalar!( + "SELECT id FROM flow_conversation WHERE id = ANY($1) AND workspace_id = $2 ORDER BY id FOR UPDATE", + &conversation_ids, + &w_id + ) + .fetch_all(&mut *tx) + .await?; + memory_deleted = sqlx::query!( + "DELETE FROM ai_agent_memory a + USING flow_conversation c + WHERE c.id = ANY($1) + AND c.workspace_id = $2 + AND a.conversation_id = c.id + AND a.workspace_id = c.workspace_id + AND NOT EXISTS ( + SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id + )", + &conversation_ids, + &w_id + ) + .execute(&mut *tx) + .await? + .rows_affected(); + conversation_deleted = sqlx::query!( + "DELETE FROM flow_conversation c + WHERE c.id = ANY($1) + AND c.workspace_id = $2 + AND NOT EXISTS ( + SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id + )", + &conversation_ids, + &w_id + ) + .execute(&mut *tx) + .await? + .rows_affected(); + } // Resolutions are not exported, so a delete-then-reimport of the same UUID would // otherwise resurrect the old annotation on a job that never carried one. @@ -737,6 +785,8 @@ pub async fn delete_jobs( + zombie_deleted + dispatch_event_deleted + conversation_message_deleted + + memory_deleted + + conversation_deleted + resolution_deleted + jobs_deleted; diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 84968ebbcc..bcc2fed94f 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -9553,6 +9553,7 @@ async fn run_preview_flow_job( &flow_path, &run_query, user_message.as_ref(), + uuid, ) .await?; } diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 21b1f56389..b62f768bbc 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -36,30 +36,20 @@ pub async fn get_or_create_conversation_with_id( title: &str, conversation_id: Uuid, ) -> Result { - // Check if conversation already exists - let existing_conversation = sqlx::query_as!( - FlowConversation, - "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by - FROM flow_conversation - WHERE id = $1 AND workspace_id = $2", - conversation_id, - w_id - ) - .fetch_optional(&mut **tx) - .await?; - - if let Some(existing) = existing_conversation { + if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? { return Ok(existing); } // Truncate title to 25 characters max let title = truncate_with_ellipsis(title, 25); - // Create new conversation with provided ID - let conversation = sqlx::query_as!( + // Every turn released by the same collector's commit finds no row: the first insert + // wins, the others wait on it, do nothing, and read the row it created. + let created = sqlx::query_as!( FlowConversation, "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by", conversation_id, w_id, @@ -67,10 +57,41 @@ pub async fn get_or_create_conversation_with_id( username, title ) - .fetch_one(&mut **tx) + .fetch_optional(&mut **tx) .await?; + if let Some(conversation) = created { + return Ok(conversation); + } - Ok(conversation) + lock_conversation(tx, w_id, conversation_id) + .await? + .ok_or_else(|| { + crate::error::Error::BadRequest(format!( + "conversation {conversation_id} belongs to another workspace" + )) + }) +} + +/// Locked, so a turn orders against retention collecting the conversation +/// (windmill_common::jobs::delete_jobs): either the turn goes first and the collector then +/// sees its message, or it waits and finds the row gone and creates it again. Unlocked, the +/// message insert would wait on the parent row's lock instead and then fail its FK check. +async fn lock_conversation( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + w_id: &str, + conversation_id: Uuid, +) -> Result> { + Ok(sqlx::query_as!( + FlowConversation, + "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by + FROM flow_conversation + WHERE id = $1 AND workspace_id = $2 + FOR UPDATE", + conversation_id, + w_id + ) + .fetch_optional(&mut **tx) + .await?) } /// Add a message to a conversation using an existing transaction diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 5541615838..6f3248f3fb 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -478,6 +478,12 @@ pub static WORKER_INTERNAL_SERVER_INLINE_UTILS: OnceCell error::Result<()> { sqlx::query!( "DELETE FROM dispatch_event WHERE producer_job_id = ANY($1)", @@ -485,12 +491,55 @@ pub async fn delete_jobs(conn: &mut sqlx::PgConnection, ids: &[uuid::Uuid]) -> e ) .execute(&mut *conn) .await?; - sqlx::query!( - "DELETE FROM flow_conversation_message WHERE job_id = ANY($1)", + let mut conversation_ids: Vec = sqlx::query_scalar!( + "DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id", ids ) - .execute(&mut *conn) + .fetch_all(&mut *conn) .await?; + conversation_ids.sort_unstable(); + conversation_ids.dedup(); + if !conversation_ids.is_empty() { + // A conversation is a view over its messages: once the last one goes with its job, + // the row and the agent's memory for it are all that is left, and nothing else + // collects them — `ai_agent_memory` carries no job id for retention to match on. + // Two statements rather than one CTE: a data-modifying CTE reads the snapshot from + // before the delete above, so every conversation would still look non-empty. + // Two calls each deleting one of a conversation's last messages would each still see + // the other's row — uncommitted deletes are invisible across transactions — so + // neither would collect it and nothing would try again. Taking the conversation row + // first serialises them: the second reads the first's delete and finds it empty. + sqlx::query_scalar!( + "SELECT id FROM flow_conversation WHERE id = ANY($1) ORDER BY id FOR UPDATE", + &conversation_ids + ) + .fetch_all(&mut *conn) + .await?; + // Memory first, since it reads the conversation row for its workspace. + sqlx::query!( + "DELETE FROM ai_agent_memory a + USING flow_conversation c + WHERE c.id = ANY($1) + AND a.conversation_id = c.id + AND a.workspace_id = c.workspace_id + AND NOT EXISTS ( + SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id + )", + &conversation_ids + ) + .execute(&mut *conn) + .await?; + sqlx::query!( + "DELETE FROM flow_conversation c + WHERE c.id = ANY($1) + AND NOT EXISTS ( + SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id + )", + &conversation_ids + ) + .execute(&mut *conn) + .await?; + } sqlx::query!("DELETE FROM zombie_job_counter WHERE job_id = ANY($1)", ids) .execute(&mut *conn) .await?; From 68f2248018fc218a090bf939e1eb22ff97d5bc22 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 17 Sep 2026 09:29:05 +0200 Subject: [PATCH 10/22] feat: keep flow inputs and seed the agent when chat mode is enabled (#11177) * feat: keep flow inputs and seed the agent when chat mode is enabled Co-Authored-By: Claude Fable 5.1 * fix: leave a linked agent's memory and streaming to the agent when enabling chat mode Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- CONTEXT.md | 19 + cli/src/guidance/skills.gen.ts | 36 ++ frontend/src/lib/common.ts | 2 + frontend/src/lib/components/ArgInput.svelte | 7 + .../lib/components/EditableSchemaForm.svelte | 9 +- .../lib/components/InputTransformForm.svelte | 5 + .../components/flows/content/FlowInput.svelte | 330 +++++++++++------- .../src/lib/components/flows/flowInfers.ts | 6 + system_prompts/auto-generated/flow.md | 36 ++ system_prompts/auto-generated/prompts.ts | 36 ++ .../auto-generated/skills/write-flow/SKILL.md | 36 ++ system_prompts/base/flow-base.md | 36 ++ 12 files changed, 433 insertions(+), 125 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 64aa1b93d8..fbc2592cc9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -37,6 +37,25 @@ _Avoid_: argument field, param Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane. _Avoid_: JS field, code input +### Flow chat + +**Conversation**: +One thread of messages against one chat-enabled flow, with its own agent memory. A flow has +many; the chat shows one at a time. +_Avoid_: thread, session (that names an AI session, a different thing), chat (that names the surface) + +**Turn**: +One question and the answer to it: the run the question started, the handle that stops it, +and the rows it is writing. At most one per conversation, and the chat is held for its whole +length — from the moment the question takes the chat, before it has a job, until it is ended. +_Avoid_: request, exchange, message round + +**Transcript**: +The rows a conversation's chat holds. Not the conversation: it is the newest page plus +whatever older pages the reader has scrolled back through, so a question it cannot answer +from what it holds is one to ask the server rather than to guess at. +_Avoid_: history, messages (too easily read as "all of them") + ### Permissions **Member**: diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index f58092fdac..e4490276ce 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5280,6 +5280,42 @@ tool, \`websearch\` for web search. "resource": "$res:", "model": }\`. Required unless the module links to a saved agent through \`value.agent\` +### Chat-Mode Flows + +A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer +sends one message per turn and renders the conversation. It needs a required \`user_message\` string +input, read by the agent. Any other flow input stays and is asked for under Configure inputs. + +\`\`\`json +{ + "id": "chat_agent", + "value": { + "type": "aiagent", + "input_transforms": { + "provider": { + "type": "static", + "value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" } + }, + "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, + "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, + "memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } }, + "streaming": { "type": "static", "value": true }, + "output_type": { "type": "static", "value": "text" } + }, + "tools": [] + } +} +\`\`\` + +- \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing +- \`streaming\` on makes the answer and its thinking appear token by token instead of all at once +- \`user_attachments\` points at a flow input typed as an array of s3 objects + (\`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }\`), so files + sent with a message reach the agent +- Running one needs a \`memory_id\` **query parameter** — not a flow argument — naming the + conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat + supplies it itself; a run driven any other way has to pass it or the server refuses the job + ### Tool Naming Rules These rules cover \`flowmodule\` tools, the ones the agent calls by name. A \`websearch\` tool's diff --git a/frontend/src/lib/common.ts b/frontend/src/lib/common.ts index 0d76b1a0b8..4f82fd252e 100644 --- a/frontend/src/lib/common.ts +++ b/frontend/src/lib/common.ts @@ -45,6 +45,8 @@ export interface SchemaProperty { required?: string[] showExpr?: string hideWhenChatEnabled?: boolean + /** Why the oneOf variant is chat mode's to pick. Set = selector disabled, reason shown. */ + lockOneOfWhenChatEnabled?: string password?: boolean order?: string[] nullable?: boolean diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index b7001059c2..eaca31c761 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -123,6 +123,8 @@ workspace?: string | undefined s3StorageConfigured?: boolean chatInputEnabled?: boolean + /** Why the oneOf variant is fixed. Set = the selector is disabled and says so. */ + oneOfLockedReason?: string actions?: import('svelte').Snippet innerBottomSnippet?: import('svelte').Snippet fieldHeaderActions?: import('svelte').Snippet @@ -184,6 +186,7 @@ workspace = undefined, s3StorageConfigured = true, chatInputEnabled = false, + oneOfLockedReason = undefined, actions, innerBottomSnippet, fieldHeaderActions, @@ -1104,11 +1107,15 @@ {:else if inputCat == 'object' || inputCat == 'resource-object' || isListJson} {#if oneOf && oneOf.length >= 2}
+ {#if oneOfLockedReason !== undefined} +
{oneOfLockedReason}
+ {/if} {#if oneOf && oneOf.length >= 2} { oneOfSelected = detail const selectedObjProperties = diff --git a/frontend/src/lib/components/EditableSchemaForm.svelte b/frontend/src/lib/components/EditableSchemaForm.svelte index 67dfeed0b8..bd3399d9d2 100644 --- a/frontend/src/lib/components/EditableSchemaForm.svelte +++ b/frontend/src/lib/components/EditableSchemaForm.svelte @@ -43,6 +43,8 @@ interface Props { schema: Schema | any hiddenArgs?: string[] + /** Fields another part of the app owns: shown, but not renameable, deletable or retypeable. */ + lockedArgs?: string[] args?: Record shouldHideNoInputs?: boolean noVariablePicker?: boolean @@ -89,6 +91,7 @@ let { schema = $bindable(), hiddenArgs = [], + lockedArgs = [], args = $bindable(undefined), shouldHideNoInputs = false, noVariablePicker = false, @@ -587,6 +590,7 @@ > {#if keys.length > 0} {#each keys as argName, i (argName)} + {@const locked = lockedArgs.includes(argName)}
@@ -605,7 +609,7 @@ >
{argName} - {#if !uiOnly} + {#if !uiOnly && !locked}
{#snippet trigger()} @@ -654,7 +658,7 @@ Required {/if} - {#if !uiOnly} + {#if !uiOnly && !locked} + + {#snippet children({ item })} + + + {/snippet} + {/if}
{/if} @@ -696,11 +796,15 @@
{#if flowStore.val.value?.chat_input_enabled}
- {#if showAdditionalInputs} -
+ {#if chatPanelTab === 'inputs'} + +
{#snippet openEditTab()} - + {@render inputsAddTrigger()} {/snippet} {/snippet}
{/if} - + +
+ +
{:else}
@@ -815,22 +917,9 @@
{#snippet close_button()} -
@@ -884,12 +973,7 @@ }} > {#snippet trigger()} -
- -
+ {@render inputsAddTrigger()} {/snippet} {/if} diff --git a/frontend/src/lib/components/flows/flowInfers.ts b/frontend/src/lib/components/flows/flowInfers.ts index bce923c77a..3c38682350 100644 --- a/frontend/src/lib/components/flows/flowInfers.ts +++ b/frontend/src/lib/components/flows/flowInfers.ts @@ -39,6 +39,12 @@ export const AI_AGENT_SCHEMA: Schema = { }, memory: { type: 'object', + // Chat mode keys memory on the conversation, so a chat whose agent has memory off + // forgets every turn. Enabling chat mode sets `auto`; this keeps it there. A step + // sitting at `off` stays switchable, or a flow that reached that state before — + // an agent added to an already-chat-enabled flow — would have no way out of it. + lockOneOfWhenChatEnabled: + "Chat mode keys this agent's history on the conversation, so memory stays on while it is enabled.", description: 'History sent between the system message and the user message.', oneOf: [ { diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index dd0048ec9c..8518068992 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -106,6 +106,42 @@ tool, `websearch` for web search. "resource": "$res:", "model": }`. Required unless the module links to a saved agent through `value.agent` +### Chat-Mode Flows + +A flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer +sends one message per turn and renders the conversation. It needs a required `user_message` string +input, read by the agent. Any other flow input stays and is asked for under Configure inputs. + +```json +{ + "id": "chat_agent", + "value": { + "type": "aiagent", + "input_transforms": { + "provider": { + "type": "static", + "value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" } + }, + "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, + "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, + "memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } }, + "streaming": { "type": "static", "value": true }, + "output_type": { "type": "static", "value": "text" } + }, + "tools": [] + } +} +``` + +- `memory` is what lets the agent see earlier turns; without it every message starts from nothing +- `streaming` on makes the answer and its thinking appear token by token instead of all at once +- `user_attachments` points at a flow input typed as an array of s3 objects + (`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }`), so files + sent with a message reach the agent +- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the + conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat + supplies it itself; a run driven any other way has to pass it or the server refuses the job + ### Tool Naming Rules These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 1e92c8e69b..9ae13dafbe 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -137,6 +137,42 @@ tool, \`websearch\` for web search. "resource": "$res:", "model": }\`. Required unless the module links to a saved agent through \`value.agent\` +### Chat-Mode Flows + +A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer +sends one message per turn and renders the conversation. It needs a required \`user_message\` string +input, read by the agent. Any other flow input stays and is asked for under Configure inputs. + +\`\`\`json +{ + "id": "chat_agent", + "value": { + "type": "aiagent", + "input_transforms": { + "provider": { + "type": "static", + "value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" } + }, + "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, + "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, + "memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } }, + "streaming": { "type": "static", "value": true }, + "output_type": { "type": "static", "value": "text" } + }, + "tools": [] + } +} +\`\`\` + +- \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing +- \`streaming\` on makes the answer and its thinking appear token by token instead of all at once +- \`user_attachments\` points at a flow input typed as an array of s3 objects + (\`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }\`), so files + sent with a message reach the agent +- Running one needs a \`memory_id\` **query parameter** — not a flow argument — naming the + conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat + supplies it itself; a run driven any other way has to pass it or the server refuses the job + ### Tool Naming Rules These rules cover \`flowmodule\` tools, the ones the agent calls by name. A \`websearch\` tool's diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 3556cbfff0..5988dd2f01 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -194,6 +194,42 @@ tool, `websearch` for web search. "resource": "$res:", "model": }`. Required unless the module links to a saved agent through `value.agent` +### Chat-Mode Flows + +A flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer +sends one message per turn and renders the conversation. It needs a required `user_message` string +input, read by the agent. Any other flow input stays and is asked for under Configure inputs. + +```json +{ + "id": "chat_agent", + "value": { + "type": "aiagent", + "input_transforms": { + "provider": { + "type": "static", + "value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" } + }, + "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, + "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, + "memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } }, + "streaming": { "type": "static", "value": true }, + "output_type": { "type": "static", "value": "text" } + }, + "tools": [] + } +} +``` + +- `memory` is what lets the agent see earlier turns; without it every message starts from nothing +- `streaming` on makes the answer and its thinking appear token by token instead of all at once +- `user_attachments` points at a flow input typed as an array of s3 objects + (`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }`), so files + sent with a message reach the agent +- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the + conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat + supplies it itself; a run driven any other way has to pass it or the server refuses the job + ### Tool Naming Rules These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index e70ac3d42a..a102710911 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -106,6 +106,42 @@ tool, `websearch` for web search. "resource": "$res:", "model": }`. Required unless the module links to a saved agent through `value.agent` +### Chat-Mode Flows + +A flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer +sends one message per turn and renders the conversation. It needs a required `user_message` string +input, read by the agent. Any other flow input stays and is asked for under Configure inputs. + +```json +{ + "id": "chat_agent", + "value": { + "type": "aiagent", + "input_transforms": { + "provider": { + "type": "static", + "value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" } + }, + "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, + "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, + "memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } }, + "streaming": { "type": "static", "value": true }, + "output_type": { "type": "static", "value": "text" } + }, + "tools": [] + } +} +``` + +- `memory` is what lets the agent see earlier turns; without it every message starts from nothing +- `streaming` on makes the answer and its thinking appear token by token instead of all at once +- `user_attachments` points at a flow input typed as an array of s3 objects + (`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }`), so files + sent with a message reach the agent +- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the + conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat + supplies it itself; a run driven any other way has to pass it or the server refuses the job + ### Tool Naming Rules These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's From 189793c2e4db7f1c853695ebcc895c1ec82ed19f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 17 Sep 2026 09:59:05 +0200 Subject: [PATCH 11/22] feat: flow chat model picker on a shared model-settings component (#11187) * refactor: render the session chat model menu from a shared ChatModelSettings config Co-Authored-By: Claude Fable 5.1 * feat: pick the flow chat's model and thinking from the provider fields the flow exposes Co-Authored-By: Claude Fable 5.1 * fix: name only the thinking level the flow run will send on the model button Co-Authored-By: Claude Opus 5 (1M context) * fix: let the flow chat take a typed model id and keep a shared thinking input editable Co-Authored-By: Claude Opus 5 (1M context) * fix: promote a flow input to the model button only where its control can edit it Co-Authored-By: Claude Opus 5 (1M context) * fix: drop any reasoning token the chosen model rejects before a flow chat run Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Fable 5.1 --- cli/src/guidance/skills.gen.ts | 25 +- .../components/AIReasoningEffortPicker.svelte | 2 +- .../lib/components/FlowPreviewContent.svelte | 1 + .../copilot/ChatModelSettings.svelte | 314 +++++++++++ .../copilot/ReasoningEffortSlider.svelte | 172 ++++++ .../copilot/chat/AIChatModelSettings.svelte | 316 +++-------- .../copilot/chatModelSettings.test.ts | 246 +++++++++ .../components/copilot/chatModelSettings.ts | 219 ++++++++ .../copilot/reasoningRegistry.test.ts | 26 +- .../components/copilot/reasoningRegistry.ts | 33 +- .../components/flows/content/FlowInput.svelte | 1 + .../flows/conversations/FlowChat.svelte | 5 + .../conversations/FlowChatInterface.svelte | 141 ++++- .../FlowChatModelSettings.svelte | 299 +++++++++++ .../conversations/agentChatInputs.test.ts | 492 ++++++++++++++++++ .../flows/conversations/agentChatInputs.ts | 441 ++++++++++++++++ .../(logged)/flows/get/[...path]/+page.svelte | 1 + system_prompts/auto-generated/flow.md | 25 +- system_prompts/auto-generated/prompts.ts | 25 +- .../auto-generated/skills/write-flow/SKILL.md | 25 +- system_prompts/base/flow-base.md | 25 +- 21 files changed, 2510 insertions(+), 324 deletions(-) create mode 100644 frontend/src/lib/components/copilot/ChatModelSettings.svelte create mode 100644 frontend/src/lib/components/copilot/ReasoningEffortSlider.svelte create mode 100644 frontend/src/lib/components/copilot/chatModelSettings.test.ts create mode 100644 frontend/src/lib/components/copilot/chatModelSettings.ts create mode 100644 frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte create mode 100644 frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts create mode 100644 frontend/src/lib/components/flows/conversations/agentChatInputs.ts diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index e4490276ce..993f546d3a 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5276,15 +5276,26 @@ tool, \`websearch\` for web search. } \`\`\` -- \`provider\` is a static object, not a bare resource string: \`{ "kind": , +- \`provider\` is an object, not a bare resource string: \`{ "kind": , "resource": "$res:", "model": }\`. Required unless the module links to a saved - agent through \`value.agent\` + agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its + fields to flow inputs instead — see below ### Chat-Mode Flows A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer sends one message per turn and renders the conversation. It needs a required \`user_message\` string -input, read by the agent. Any other flow input stays and is asked for under Configure inputs. +input, read by the agent. Any other flow input the composer does not edit itself is asked for +under Configure inputs. + +**A static \`provider\` gives a chat that cannot change its model.** Feed it from flow inputs +instead, either way round: one input carrying the whole object (\`"expr": "flow_input.model_config"\`) +makes every field editable, or wire it field by field to fix some and expose others. A field the +chat can write becomes a control in the composer — a provider picker, a model list, a thinking +control — and a field left static is fixed, with no control drawn for it. \`kind\` is the one +exception: the composer writes it only together with \`resource\`, since a provider is picked as a +pair, so a \`kind\` input wired on its own stays askable under Configure inputs and nothing the run +needs becomes unreachable. \`\`\`json { @@ -5293,8 +5304,8 @@ input, read by the agent. Any other flow input stays and is asked for under Conf "type": "aiagent", "input_transforms": { "provider": { - "type": "static", - "value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" } + "type": "javascript", + "expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })" }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, @@ -5307,6 +5318,10 @@ input, read by the agent. Any other flow input stays and is asked for under Conf } \`\`\` +- Wiring field by field means one object literal whose values are literals or bare \`flow_input.x\` + references. A spread, a call or a computed key leaves the composer unable to tell which input + feeds which field, so it offers no control at all — a bare \`flow_input.x\` for the whole object + is read instead as that one input carrying every field - \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing - \`streaming\` on makes the answer and its thinking appear token by token instead of all at once - \`user_attachments\` points at a flow input typed as an array of s3 objects diff --git a/frontend/src/lib/components/AIReasoningEffortPicker.svelte b/frontend/src/lib/components/AIReasoningEffortPicker.svelte index 3ebf94c9bf..39b4e80011 100644 --- a/frontend/src/lib/components/AIReasoningEffortPicker.svelte +++ b/frontend/src/lib/components/AIReasoningEffortPicker.svelte @@ -27,7 +27,7 @@ let capability = $derived( provider && model ? getReasoningCapability(provider, model) - : { supported: false, levels: [], canDisable: false } + : { supported: false, levels: [], canDisable: false, known: false } ) // The token that turns reasoning off on a model that reasons by default diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index f6f6da0cc9..cd2460b1b0 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -473,6 +473,7 @@ hideSidebar={true} path={$pathStore} inputSchema={flowStore.val.schema} + flowModules={flowStore.val.value?.modules} />
{:else} diff --git a/frontend/src/lib/components/copilot/ChatModelSettings.svelte b/frontend/src/lib/components/copilot/ChatModelSettings.svelte new file mode 100644 index 0000000000..a1e677d46b --- /dev/null +++ b/frontend/src/lib/components/copilot/ChatModelSettings.svelte @@ -0,0 +1,314 @@ + + +{#snippet trigger()} +
+ +
+{/snippet} + +{#snippet typedField( + value: string, + placeholder: string, + onCommit: (value: string) => void, + close: () => void +)} + {#key value} + onCommit(e.currentTarget.value.trim()), + // Capture, not bubble: Svelte delegates `keydown` to the root, which sits above the + // menu — so a bubble handler here would run only after melt's own listener had read + // the key as typeahead and moved focus. A capture key is not delegatable, so this + // becomes a real listener on the input and sees the event first. + onkeydowncapture: (e) => { + // Escape cancels: let it reach the menu with the value untouched. + if (e.key === 'Escape') return + // Tab closes the menu, unmounting this field before focus moves, so no change + // event would ever fire. Commit on the way past. + if (e.key === 'Tab') { + onCommit(e.currentTarget.value.trim()) + return + } + // Enter means done: commit and close, rather than leaving the menu open around a + // field the commit is about to rebuild. + if (e.key === 'Enter') { + e.preventDefault() + onCommit(e.currentTarget.value.trim()) + close() + return + } + // Everything else is typing; the menu reads loose keys as typeahead. + e.stopPropagation() + } + }} + /> + {/key} +{/snippet} + +{#snippet section(sec: ChoiceSection, item: MeltItem, close: () => void)} +
{sec.label}
+ {#if sec.loading} +
+ Loading... +
+ {:else if sec.options.length === 0} +
{sec.emptyMessage ?? 'Nothing to choose from'}
+ {:else} +
+ {#each sec.options as option (option.key)} + option.onSelect()}> + {option.label} + {#if option.hint} + {option.hint} + {/if} + {#if option.selected} + + {/if} + + {/each} +
+ {/if} + {#if sec.custom && !sec.loading} + {@const custom = sec.custom} +
+ {@render typedField( + '', + custom.placeholder, + (value) => { + if (value) custom.onCommit(value) + }, + close + )} +
+ {/if} +{/snippet} + +{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)} + {#each items.filter((row) => !row.hide) as row (row.displayName)} + {#if row.separatorTop} +
+ {/if} + {#if row.submenuItems} + + + {:else} + row.action?.(e)}> + {#if row.icon} + + {/if} + {row.displayName} + {#if row.selected} + + {/if} + + {/if} + {/each} +{/snippet} + +{#if config.readOnly} + {@render trigger()} +{:else} + + {#snippet buttonReplacement()} + {@render trigger()} + {/snippet} + {#snippet menu({ item, builders, close })} +
+ {#if config.topItems} +
+ {@render rows(config.topItems(close), item, builders)} +
+ {/if} + {#each config.sections ?? [] as sec (sec.label)} +
+ {@render section(sec, item, close)} +
+ {/each} + {#if reasoning} +
+ {#if controlState === 'fixed'} + {}} + unsupportedReason={fixedReason} + /> + {:else if controlState === 'awaiting-model'} + {}} + unsupportedReason="Pick a model first" + /> + {:else if controlState === 'unknown'} + +
+
Thinking
+ {@render typedField(reasoning.value ?? '', 'none', reasoning.onSelect, close)} +
+ Windmill has no thinking levels for this provider — type what it accepts. +
+
+ {:else if controlState === 'ladder'} + + effortSlider?.adjust(e)} + class="block group" + > + (stop === reasoning?.offToken ? 'off' : stop)} + overrideLabel={stops.includes(currentStop) ? undefined : effortLabel} + /> + + {:else} + + {}} + unsupportedReason="Not supported by this model" + /> + {/if} +
+ {/if} + {#if config.bottomItems} +
+ {@render rows(config.bottomItems(close), item, builders)} +
+ {/if} +
+ {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/copilot/ReasoningEffortSlider.svelte b/frontend/src/lib/components/copilot/ReasoningEffortSlider.svelte new file mode 100644 index 0000000000..24ea4cf8aa --- /dev/null +++ b/frontend/src/lib/components/copilot/ReasoningEffortSlider.svelte @@ -0,0 +1,172 @@ + + +{#if unsupportedReason} + +
+
Thinking
+
{unsupportedReason}
+
+{:else} +
+ Thinking + {overrideLabel ?? format(current)} +
+ {#if stops.length > 1} + +
+ onSelect(stops[+e.currentTarget.value])} + onclick={(e) => { + // `click`, not `pointerup`: it is the event that means pressed and released on + // the track, so a press that began on the row above cannot commit an effort + // nobody chose. Only the click that moved nothing — any other stop has already + // committed through `oninput`, and doing it again would write it twice. + if (!hasPosition && +e.currentTarget.value === stopIndex) { + onSelect(stops[stopIndex]) + } + }} + use:isolatePointer + class="lean-range no-default-style w-full" + aria-label="Reasoning effort" + /> +
+ {/if} +{/if} + + diff --git a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte index 9dd94fa4ea..914ad51bc9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte @@ -1,10 +1,12 @@ {#snippet externalLinkIcon()} {/snippet} - - {#snippet buttonReplacement()} -
- -
- {/snippet} - {#snippet menu({ item, builders, close })} -
- - {#if promptSettings} - - {/if} + -
-
Model
-
- {#each models as m (m.provider + m.model)} - selectModel(m)} - > - {m.model} - {#if m.model === providerModel.model && m.provider === providerModel.provider} - - {/if} - - {/each} -
- -
- {#if capability.supported} - - -
- Thinking - {currentStop} -
- {#if stops.length > 1} - -
- selectReasoning(stops[+e.currentTarget.value])} - use:isolatePointer - class="lean-range no-default-style w-full" - aria-label="Reasoning effort" - /> -
- {/if} -
- {:else} - -
-
Thinking
-
Not supported by this model
-
- {/if} - - - (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)} - > - Always expand thinking - {#if thinkingPreferences.expandByDefault} - - {/if} - -
- {/snippet} -
- - {#if promptSettings} {/if} - - diff --git a/frontend/src/lib/components/copilot/chatModelSettings.test.ts b/frontend/src/lib/components/copilot/chatModelSettings.test.ts new file mode 100644 index 0000000000..04d1834556 --- /dev/null +++ b/frontend/src/lib/components/copilot/chatModelSettings.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' +import { + carriedReasoning, + fixedReasoningReason, + reasoningControlState, + reasoningDisplay, + REASONING_PROVIDER_DEFAULT, + type ChatModelSettingsReasoning +} from './chatModelSettings' +import { + getReasoningCapability, + REASONING_OFF, + resolveEffectiveReasoning +} from './reasoningRegistry' + +/** + * The trigger's suffix and the slider's stops are read side by side, so they have to agree + * about one value — the provider-native off token must not read as `none` on one and `off` + * on the other, and an effort the run does not send must not be named at all. + */ +function display( + reasoning: Partial & { provider: any; model: string } +) { + const full = { + value: undefined, + offToken: undefined, + sendsDefaultWhenUnset: false, + onSelect: () => {}, + ...reasoning + } as ChatModelSettingsReasoning + const capability = getReasoningCapability(full.provider, full.model) + // Composed exactly as the component composes it, so the test exercises the real pair. + const effective = resolveEffectiveReasoning({ + provider: full.provider, + model: full.model, + reasoning: full.value + }) + return reasoningDisplay(full, capability, effective) +} + +describe('reasoningDisplay', () => { + it('says nothing for a model that cannot reason', () => { + const shown = display({ provider: 'openai', model: 'gpt-4o' }) + expect(shown.label).toBeUndefined() + expect(shown.stops).toEqual([]) + }) + + // The session chat's own sentinel: what it stores is already the word the reader sees. + it('reads the session chat off sentinel as off', () => { + const shown = display({ + provider: 'openai', + model: 'gpt-5.1', + offToken: REASONING_OFF, + value: REASONING_OFF, + sendsDefaultWhenUnset: true + }) + expect(shown.label).toBe(REASONING_OFF) + expect(shown.currentStop).toBe(REASONING_OFF) + }) + + // An agent writes the provider's own token, which can read as anything. + it('reads a provider-native off token as off too', () => { + const shown = display({ + provider: 'openai', + model: 'gpt-5.1', + offToken: 'none', + value: 'none' + }) + expect(shown.label).toBe(REASONING_OFF) + expect(shown.currentStop).toBe('none') + expect(shown.stops[0]).toBe('none') + }) + + it('names the level a chat that fills one in will send', () => { + const shown = display({ + provider: 'openai', + model: 'gpt-5.1', + offToken: REASONING_OFF, + value: undefined, + sendsDefaultWhenUnset: true + }) + expect(shown.label).toBe('high') + }) + + // An agent step omits the field, so naming a level would claim something untrue. + it('names no level where an unset effort is simply not sent', () => { + const shown = display({ + provider: 'anthropic', + model: 'claude-sonnet-5', + offToken: 'none', + value: undefined + }) + expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT) + expect(shown.currentStop).toBe('') + }) + + // Claude 4.x only thinks when asked, so an absent effort is already off — and the flow + // chat must be able to get back to it after a level has been picked. + it('offers omission as the off stop where that is how the model disables', () => { + const unset = display({ provider: 'anthropic', model: 'claude-opus-4-6', offToken: '' }) + expect(unset.label).toBe(REASONING_OFF) + expect(unset.stops[0]).toBe('') + const picked = display({ + provider: 'anthropic', + model: 'claude-opus-4-6', + offToken: '', + value: 'high' + }) + expect(picked.currentStop).toBe('high') + expect(picked.stops).toContain('') + }) + + // gpt-5 reasons at medium with no effort sent, so an empty off token buys no off stop. + it('offers no off where the model cannot stop thinking', () => { + const shown = display({ provider: 'openai', model: 'gpt-5', offToken: '' }) + expect(shown.stops).not.toContain('') + expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT) + }) + + // The run sends an explicitly set effort whatever the model, so a token typed against a + // provider we have no rules for has to reach the trigger — silence would hide it. + it('names a set effort even where it can offer no ladder', () => { + const shown = display({ provider: 'customai', model: 'deepseek-r1', value: 'high' }) + expect(shown.label).toBe('high') + expect(shown.stops).toEqual([]) + }) +}) + +describe('carriedReasoning', () => { + const cap = (model: string) => getReasoningCapability('openai', model) + + // A level carried onto a model that cannot think stays in the flow input, and the run sends it. + it('drops a level the new model does not have', () => { + expect(carriedReasoning('high', '', cap('gpt-4o'))).toBeUndefined() + expect(carriedReasoning('xhigh', '', cap('gpt-5.1'))).toBeUndefined() + }) + + it('keeps a level the new model does have', () => { + expect(carriedReasoning('high', '', cap('gpt-5.1'))).toBe('high') + }) + + it('carries off only onto a model that can truly stop thinking', () => { + expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5.1'))).toBe(REASONING_OFF) + expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5'))).toBeUndefined() + }) + + // A provider the registry has no rules for draws no thinking control, so a carried level + // would be invisible and unclearable — and still sent, since an explicitly set effort + // goes out whatever the model. + it('drops the effort where it has no rules for the provider', () => { + expect( + carriedReasoning('high', REASONING_OFF, getReasoningCapability('customai', 'deepseek-r1')) + ).toBeUndefined() + }) + + it('has nothing to carry when no effort is set', () => { + expect(carriedReasoning(undefined, '', cap('gpt-5.1'))).toBeUndefined() + expect(carriedReasoning('', '', cap('gpt-5.1'))).toBeUndefined() + }) +}) + +const asReasoning = (over: Partial): ChatModelSettingsReasoning => + ({ + provider: 'openai', + model: 'gpt-5.1', + value: undefined, + offToken: REASONING_OFF, + sendsDefaultWhenUnset: false, + writable: true, + typedWhenUnknown: true, + onSelect: () => {}, + ...over + }) as ChatModelSettingsReasoning + +/** The control is always drawn; this is the only thing that decides what it draws. */ +describe('reasoningControlState', () => { + const cap = (model: string) => getReasoningCapability('openai', model) + + it('shows the ladder for a model with levels', () => { + expect(reasoningControlState(asReasoning({}), cap('gpt-5.1'))).toBe('ladder') + }) + + it('says a model cannot think when the registry knows it cannot', () => { + expect(reasoningControlState(asReasoning({ model: 'gpt-4o' }), cap('gpt-4o'))).toBe( + 'unsupported' + ) + }) + + // Not the same as "cannot think": we have no rules for the provider, so the flow's own + // token is typed rather than picked. + it('asks for a typed token where it has no rules for the provider', () => { + expect( + reasoningControlState( + asReasoning({ provider: 'customai', model: 'deepseek-r1' }), + getReasoningCapability('customai', 'deepseek-r1') + ) + ).toBe('unknown') + }) + + // The session chat has no typed effort: a provider with no rules reads as unable to think. + it('offers no typed token to a chat that does not take one', () => { + expect( + reasoningControlState( + asReasoning({ provider: 'customai', model: 'deepseek-r1', typedWhenUnknown: false }), + getReasoningCapability('customai', 'deepseek-r1') + ) + ).toBe('unsupported') + }) + + // A provider with a full ladder must not be described as unreadable just because no + // model has been picked yet — which is the state right after choosing a resource. + it('waits for a model rather than blaming the provider', () => { + expect( + reasoningControlState(asReasoning({ model: undefined }), { supported: false, known: false }) + ).toBe('awaiting-model') + }) + + it('shows what the flow fixed when this chat cannot write it', () => { + expect(reasoningControlState(asReasoning({ writable: false }), cap('gpt-5.1'))).toBe('fixed') + }) +}) + +describe('fixedReasoningReason', () => { + it('names the level the run will use', () => { + expect( + fixedReasoningReason(asReasoning({ value: 'high' }), { supported: true, known: true }) + ).toBe('high · set in the flow') + }) + + // The step naming no effort at all is the common shape; saying it was "set in the flow" + // would describe a line the flow does not contain. + it('does not claim a level the step never set', () => { + expect( + fixedReasoningReason(asReasoning({ value: undefined }), { supported: true, known: true }) + ).toBe('Not set in the flow, so the provider decides') + }) + + it('surfaces a level fixed on a model that cannot use it', () => { + expect( + fixedReasoningReason(asReasoning({ value: 'high', model: 'gpt-4o' }), { + supported: false, + known: true + }) + ).toContain('cannot think') + }) +}) diff --git a/frontend/src/lib/components/copilot/chatModelSettings.ts b/frontend/src/lib/components/copilot/chatModelSettings.ts new file mode 100644 index 0000000000..3fce0270d1 --- /dev/null +++ b/frontend/src/lib/components/copilot/chatModelSettings.ts @@ -0,0 +1,219 @@ +import type { AIProvider } from '$lib/gen' +import type { Item } from '$lib/utils' +import { REASONING_OFF } from './reasoningRegistry' + +/** + * The contract between a chat and its model button. + * + * One component renders this menu for every chat — the copilot's own session chat and + * the flow chat — so the component knows only about rows, choices and a reasoning + * ladder. What a row means (a workspace AI resource, a prompt to edit, a reading + * preference) is the caller's business, and each caller derives its own config: a fixed + * one for the session chat, one derived from the flow's exposed inputs for flow chat. + */ + +export type ModelChoice = { + /** Stable across rebuilds of the config; used as the `{#each}` key. */ + key: string + label: string + /** Muted trailing text, e.g. the provider a resource speaks. */ + hint?: string + selected: boolean + onSelect: () => void +} + +export type ChoiceSection = { + /** Section heading, e.g. 'Provider' or 'Model'. */ + label: string + options: ModelChoice[] + /** Fetched lists render one consistent loading line instead of the options. */ + loading?: boolean + /** Shown when the list is empty and settled. */ + emptyMessage?: string + maxHeight?: string + /** + * A typed entry under the options, for a value the list does not hold: an endpoint with no + * listing, or a model newer than the catalogue. An empty entry commits nothing. + */ + custom?: { placeholder: string; onCommit: (value: string) => void } +} + +export type ChatModelSettingsConfig = { + /** The trigger's main text: the chosen model, or an invitation to choose one. */ + label: string + title?: string + /** Trailing pill on the trigger, e.g. the free-tier grant this chat is spending. */ + badge?: { text: string; warn?: boolean } + /** Nothing here is editable — the trigger still names the model, but no menu opens. */ + readOnly?: boolean + readOnlyReason?: string + /** + * Rows above and below the choice sections. Given the menu's own `close` because a + * row that opens a modal must close the menu first, while a row that toggles a + * preference must not. + */ + topItems?: (close: () => void) => Item[] + sections?: ChoiceSection[] + bottomItems?: (close: () => void) => Item[] + /** + * The thinking slider. The ladder is derived from the provider and model here rather + * than by each caller, so a new provider's effort levels reach every chat at once. + * `value` is the raw stored effort (undefined meaning the model's default), and + * `offToken` the token this caller stores for "off" — the copilot keeps its own + * sentinel and translates when it calls the provider, an agent writes the + * provider-native token straight into its step. + */ + reasoning?: ChatModelSettingsReasoning +} + +export type ChatModelSettingsReasoning = { + /** Absent until the chat knows what it will run; the ladder then has nothing to stand on. */ + provider: AIProvider | undefined + model: string | undefined + value: string | undefined + offToken: string | undefined + /** + * What an unset value means on the wire. The copilot fills one in before calling the + * provider, so unset really runs at the default effort and the button says so. An agent + * step omits the field entirely, so unset means whatever the provider does by itself — + * naming a level there would state something the run does not do. + */ + sendsDefaultWhenUnset: boolean + /** + * Whether this chat can write the effort back. False where the flow fixes it in the step: + * the run still uses it, so the button shows it and refuses to pretend otherwise — only + * the flow editor can change it. + */ + writable: boolean + /** + * Whether a provider the registry has no rules for gets a typed effort field. True for an + * agent, which writes the token straight into its step. False for the copilot, which then + * shows the model as unable to think. + */ + typedWhenUnknown: boolean + onSelect: (token: string) => void +} + +/** What an unset agent effort reads as: the provider decides, and we do not know what. */ +export const REASONING_PROVIDER_DEFAULT = 'default' + +/** + * The effort to keep when the model changes, or nothing where the new model has no such + * level. Dropped rather than carried because a model that cannot think at that level either + * rejects the request or quietly runs at another one, and the button would name a level the + * run never used. Off survives only onto a model that can truly disable. + * + * A model the registry has no rules for drops it too, for the same reason: a chat without + * `typedWhenUnknown` draws no thinking control there, so a carried level would be invisible + * and unclearable while still going out on the wire — `resolveEffectiveReasoning` sends an explicitly set effort + * whatever the model, and a provider that rejects the field would then fail every turn with + * nothing on screen to explain it. + */ +export function carriedReasoning( + current: string | undefined, + offToken: string | undefined, + capability: { levels: string[]; canDisable: boolean } +): string | undefined { + if (current === undefined || current === '') return undefined + if (offToken !== undefined && current === offToken) { + return capability.canDisable ? current : undefined + } + return capability.levels.includes(current) ? current : undefined +} + +/** + * What the menu shows for the reasoning ladder: the stops the slider offers, the one it + * sits on, and the suffix on the trigger. + * + * Pure and here rather than in the component because these three have to agree — a stop + * the slider renders as `off` must not read as the provider's own `none` on the button — + * and because the rules are provider-shaped enough to be worth testing directly. + */ +export function reasoningDisplay( + reasoning: ChatModelSettingsReasoning | undefined, + capability: { supported: boolean; levels: string[]; canDisable: boolean }, + effective: string | undefined +): { + stops: string[] + currentStop: string + /** Trigger suffix, or undefined when there is nothing truthful to say. */ + label: string | undefined +} { + if (!reasoning) return { stops: [], currentStop: '', label: undefined } + if (!capability.supported) { + // No ladder to place it on, but an effort that is explicitly set still goes out — + // `resolveEffectiveReasoning` sends one whatever the model — so the button names it. + // Saying nothing would hide from the reader what the run is about to do. + const set = reasoning.value ? reasoning.value : undefined + return { stops: [], currentStop: '', label: set } + } + // An off position only where the model can truly disable, else the provider would + // coerce it to the lowest level; then the provider-native levels. + const offToken = capability.canDisable ? reasoning.offToken : undefined + const stops = [...(offToken !== undefined ? [offToken] : []), ...capability.levels] + // An agent whose model disables by omission stores the empty string, which the run + // treats as no effort at all — so an unset value already sits on that stop. + const isOff = offToken !== undefined && (reasoning.value ?? '') === offToken + if (isOff) { + // The off token is provider-native and can read as anything ('none', 'disabled'); + // on the button and on the slider it always reads as off. + return { stops, currentStop: offToken as string, label: REASONING_OFF } + } + if (reasoning.value === undefined || reasoning.value === '') { + // Where the provider takes an explicit disable, unset is a third state — the + // provider's own level, above off — that the ladder has no position for. The + // button still names it, and every stop the ladder does offer stays reachable. + return reasoning.sendsDefaultWhenUnset + ? { stops, currentStop: effective ?? '', label: effective ?? REASONING_OFF } + : { stops, currentStop: '', label: REASONING_PROVIDER_DEFAULT } + } + return { stops, currentStop: reasoning.value, label: reasoning.value } +} + +/** Which thinking control a chat should draw. */ +export type ReasoningControlState = + /** The flow sets the effort itself; show what the run will use. */ + | 'fixed' + /** No model chosen yet, so nothing can be said about its levels. */ + | 'awaiting-model' + /** No rules for this provider, and the chat takes a typed token. */ + | 'unknown' + /** Known levels — the ladder. */ + | 'ladder' + /** Known to have none. */ + | 'unsupported' + +/** + * The control is always drawn; only its state varies. Decided here rather than in the markup + * so the states sit in one readable, testable place. + */ +export function reasoningControlState( + reasoning: ChatModelSettingsReasoning | undefined, + capability: { supported: boolean; known: boolean } +): ReasoningControlState { + if (!reasoning || !reasoning.writable) return 'fixed' + if (!reasoning.model) return 'awaiting-model' + if (!capability.known && reasoning.typedWhenUnknown) return 'unknown' + return capability.supported ? 'ladder' : 'unsupported' +} + +/** + * What the row says when the chat cannot write the effort. Naming the level is the point: a + * button that names the model a run will use should name its thinking too, and a level fixed + * on a model that cannot use one is a broken flow worth seeing rather than a silent row. + */ +export function fixedReasoningReason( + reasoning: ChatModelSettingsReasoning | undefined, + capability: { supported: boolean; known: boolean } +): string { + const cannotThink = capability.known && !capability.supported + const model = reasoning?.model ?? 'this model' + if (!reasoning?.value) { + // The step names no effort, so the provider decides — saying it was "set in the flow" + // would describe a line the flow does not contain. + return cannotThink ? `${model} cannot think` : 'Not set in the flow, so the provider decides' + } + return cannotThink + ? `${reasoning.value} · set in the flow, but ${model} cannot think` + : `${reasoning.value} · set in the flow` +} diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts index b4c935bcaa..faf6f60264 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts @@ -77,9 +77,9 @@ describe('supportsReasoning (static registry)', () => { } // Bedrock translates the same sentinel on its Converse path, but only for // Opus 5 — AWS documents Bedrock's Sonnet 5 as always thinking. - expect( - getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable - ).toBe(true) + expect(getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable).toBe( + true + ) expect( resolveRequestReasoning({ provider: 'aws_bedrock', @@ -189,13 +189,29 @@ describe('supportsReasoning (static registry)', () => { expect(supportsReasoning('mistral', 'mistral-medium-3.5')).toBe(true) expect(getReasoningCapability('mistral', 'mistral-medium-3-5').canDisable).toBe(true) }) - it('returns no levels for providers without a registry entry', () => { + it('returns no levels for a model its provider family has no entry for', () => { + // The family is known, so the `false` is an answer: codestral does not reason. expect(getReasoningCapability('mistral', 'codestral-latest')).toEqual({ supported: false, levels: [], - canDisable: false + canDisable: false, + known: true }) }) + + // `customai` fronts any OpenAI-compatible endpoint, so `supported: false` there is an + // absence of rules rather than a fact about the model. A caller that shows the reader + // "this model cannot think" has to tell the two apart. + it('admits when it has no rules for the provider at all', () => { + expect(getReasoningCapability('customai', 'deepseek-r1')).toEqual({ + supported: false, + levels: [], + canDisable: false, + known: false + }) + expect(getReasoningCapability('openai', 'gpt-4o').known).toBe(true) + expect(getReasoningCapability('anthropic', 'claude-sonnet-5').known).toBe(true) + }) it('only offers off where the model can truly disable thinking', () => { // Gemini Pro enforces a thinking floor — no off option. expect(getReasoningCapability('googleai', 'gemini-2.5-pro').canDisable).toBe(false) diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 95a00e28c8..242f6cb329 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -231,14 +231,35 @@ export type ReasoningCapability = { * level, making the switch a lie. */ canDisable: boolean + /** + * Whether `supported` is an answer or an absence of one. The registry has rules per + * provider family and falls through to `false` for the rest — `customai` above all, + * which fronts any OpenAI-compatible endpoint and may well serve a thinking model. A + * caller that presents `supported: false` as a fact must check this first, or it tells + * the reader a model cannot think when all we know is that we have never heard of it. + */ + known: boolean } +/** Provider families the registry has real rules for; everything else is a shrug. */ +const KNOWN_REASONING_FAMILIES: ReadonlySet = new Set([ + 'anthropic', + 'aws_bedrock', + 'openai', + 'azure_openai', + 'openrouter', + 'googleai', + 'deepseek', + 'mistral' +]) + /** Resolve the reasoning capability of a model from the static registry. */ export function getReasoningCapability(provider: AIProvider, model: string): ReasoningCapability { const bareModel = stripLegacyThinkingSuffix(model) + const known = KNOWN_REASONING_FAMILIES.has(reasoningProviderFamily(provider, bareModel)) const supported = supportsReasoningStatic(provider, bareModel) if (!supported) { - return { supported: false, levels: [], canDisable: false } + return { supported: false, levels: [], canDisable: false, known } } const family = reasoningProviderFamily(provider, bareModel) const levels = @@ -251,7 +272,7 @@ export function getReasoningCapability(provider: AIProvider, model: string): Rea : family === 'openrouter' ? openrouterReasoningLevels(bareModel) : (PROVIDER_REASONING_LEVELS[family] ?? ['low', 'medium', 'high']) - return { supported, levels, canDisable: canDisableReasoning(provider, bareModel) } + return { supported, levels, canDisable: canDisableReasoning(provider, bareModel), known } } /** @@ -362,15 +383,11 @@ export function explicitOffToken(provider: AIProvider, model: string): Reasoning // real off there and stays the wire form. Only the 5 family, which // thinks when the field is absent, needs the explicit disable — // Fable and Mythos reject it outright and get no off token at all. - return /claude-(opus|sonnet)-5/.test(model.toLowerCase()) - ? ANTHROPIC_OFF_SENTINEL - : undefined + return /claude-(opus|sonnet)-5/.test(model.toLowerCase()) ? ANTHROPIC_OFF_SENTINEL : undefined case 'aws_bedrock': // Bedrock's Sonnet 5 cannot be disabled at all, so only Opus 5 gets // the sentinel; the rest keep omission. - return model.toLowerCase().includes('claude-opus-5') - ? ANTHROPIC_OFF_SENTINEL - : undefined + return model.toLowerCase().includes('claude-opus-5') ? ANTHROPIC_OFF_SENTINEL : undefined case 'googleai': // Gemini 2.5/3 think by default (dynamic budget / level). The backend // proxy maps 'none' to off on Flash, or the floor on Pro (only diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index 7d4483d577..eb78dbdc89 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -851,6 +851,7 @@ path={$pathStore} hideSidebar={true} inputSchema={flowStore.val.schema} + flowModules={flowStore.val.value?.modules} />
diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index dc4eb92b57..1025ddaa15 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -6,6 +6,7 @@ import FlowChatInterface from './FlowChatInterface.svelte' import { getContext } from 'svelte' import type { FlowEditorContext } from '../types' + import type { FlowModule } from '$lib/gen' interface Props { /** @@ -22,6 +23,8 @@ path: string hideSidebar?: boolean inputSchema?: Record + /** The flow's modules, read for the provider wiring of its AI agent steps. */ + flowModules?: FlowModule[] /** The flow's description, shown under the empty transcript's prompt. */ description?: string wideLayout?: boolean @@ -33,6 +36,7 @@ path, hideSidebar = false, inputSchema = undefined, + flowModules = undefined, description = undefined, wideLayout = false }: Props = $props() @@ -102,6 +106,7 @@ {chat} {deploymentInProgress} {additionalInputsSchema} + {flowModules} {path} {workspace} {description} diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index 535be3ca83..f6aa0670c4 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -10,11 +10,23 @@ import { emptyString, type DynamicInput } from '$lib/utils' import { onDestroy, tick, untrack } from 'svelte' import type { Chat } from 'windmill-chat' + import type { FlowModule } from '$lib/gen' + import { deepEqual } from 'fast-equals' + import FlowChatModelSettings from './FlowChatModelSettings.svelte' + import { + agentModelGap, + composerOwnedInputs, + resolveAgentModelWiring, + showsModelButton, + withoutRejectedEffort + } from './agentChatInputs' interface Props { chat: Chat deploymentInProgress?: boolean additionalInputsSchema?: Record + /** The flow's modules, read for the provider wiring of its AI agent steps. */ + flowModules?: FlowModule[] path: string workspace?: string /** The flow's description, shown under the empty transcript's prompt. */ @@ -26,6 +38,7 @@ chat, deploymentInProgress = false, additionalInputsSchema, + flowModules, path, workspace = undefined, description = undefined, @@ -42,14 +55,45 @@ return undefined }) + // The model gets its own button, shaped like the copilot's model settings, driven by + // whichever provider fields the flow exposes. Every other flow input is asked for in + // the Configure-inputs modal. + const modelWiring = $derived(resolveAgentModelWiring(flowModules)) + // An agent with nothing to call cannot answer, and the composer cannot fix it, so the + // chat says what to go and do instead of offering controls that write nowhere. + const modelGap = $derived(agentModelGap(modelWiring)) + const showModelButton = $derived(showsModelButton(modelWiring)) + // LocalStorage helpers const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_' - // State for additional inputs modal let showInputsModal = $state(false) - let additionalInputsValues = $state | undefined>( - loadInputsFromStorage() ?? undefined - ) + // Conversation settings, persisted per flow: what the reader chose, and nothing else. + let inputValues = $state>(loadInputsFromStorage() ?? {}) + let modalDraft = $state>({}) + + /** What the flow's own form would open on. */ + function schemaDefaults(schema: Record | undefined): Record { + const properties: Record = schema?.properties ?? {} + return Object.fromEntries( + Object.entries(properties) + .filter(([, property]) => property?.default !== undefined) + .map(([name, property]) => [name, property.default]) + ) + } + + // Derived rather than seeded into `inputValues`: the schema arrives with the flow, which + // on the deployed page is after this mounts, and only what the reader actually chose + // belongs in storage. A stored value wins over the default, including a deliberate empty. + const effectiveInputs = $derived({ + ...schemaDefaults(additionalInputsSchema), + ...inputValues + }) + + // What the run actually gets. The composer's own controls keep themselves consistent as + // they are used; this is where a pair that was never chosen through them — a stored + // value, an author's default — is made safe before it reaches the provider. + const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs)) function getStorageKey(): string { return `${STORAGE_KEY_PREFIX}${path}` @@ -73,40 +117,72 @@ } } + function setInputValue(name: string, value: any) { + inputValues = { ...inputValues, [name]: value } + saveInputsToStorage(inputValues) + } + function handleModalConfirm() { - saveInputsToStorage(additionalInputsValues ?? {}) + // The modal opens on `effectiveInputs`, so its draft carries a value for every + // defaulted input whether or not the reader touched one. Storing those would pin + // today's defaults for good — `effectiveInputs` gives a stored value precedence, so + // a later change to the flow's schema would never reach this reader again. + const defaults = schemaDefaults(additionalInputsSchema) + const kept = Object.fromEntries( + Object.entries({ ...inputValues, ...modalDraft }).filter( + ([name, value]) => !deepEqual(value, defaults[name]) + ) + ) + inputValues = kept + saveInputsToStorage(inputValues) showInputsModal = false } function openInputsModal() { - const stored = loadInputsFromStorage() - if (stored) additionalInputsValues = stored + modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) } showInputsModal = true } - const hasMissingRequired = $derived.by(() => { - if (!additionalInputsSchema?.required?.length) return false - const values = additionalInputsValues ?? {} - return additionalInputsSchema.required.some( - (field: string) => - values[field] === undefined || values[field] === '' || values[field] === null - ) - }) - // The host follows the chat it was built on for the life of this component: FlowChat // remounts the interface under `{#key chat}`, so a later value of the prop never reaches it. const chatHost = new FlowChatViewHost( untrack(() => chat), { - additionalInputs: () => - additionalInputsSchema ? (loadInputsFromStorage() ?? additionalInputsValues) : undefined, + additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined), workspace: () => workspace, - sendDisabled: () => deploymentInProgress + sendDisabled: () => deploymentInProgress || !!modelGap } ) setChatViewHost(chatHost) onDestroy(() => chatHost.dispose()) + // What the Configure-inputs modal asks for: every flow input the composer does not + // edit itself. + const modalSchema = $derived.by(() => { + if (!additionalInputsSchema) return undefined + const promoted = new Set(composerOwnedInputs(modelWiring, undefined)) + const properties = Object.fromEntries( + Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => !promoted.has(key)) + ) + if (Object.keys(properties).length === 0) return undefined + const required: string[] = Array.isArray(additionalInputsSchema.required) + ? additionalInputsSchema.required + : [] + return { + ...additionalInputsSchema, + properties, + required: required.filter((key) => !promoted.has(key)) + } + }) + + const modalMissingRequired = $derived.by(() => { + if (!modalSchema?.required?.length) return false + return modalSchema.required.some((field: string) => { + const value = effectiveInputs[field] + return value === undefined || value === '' || value === null + }) + }) + // Older pages load when the reader reaches the top; the viewport stays where it was. let scrollElement = $state(undefined) let loadingOlder = false @@ -126,12 +202,11 @@ } - -{#if additionalInputsSchema} +{#if modalSchema} @@ -159,7 +234,7 @@ {/snippet} {#snippet footerSettings()} - {#if additionalInputsSchema} + {#if modalSchema}
- {#if hasMissingRequired} + {#if modalMissingRequired} {/if}
{/if} + {#if modelWiring && showModelButton} + + + {/if} {/snippet} @@ -111,6 +125,7 @@ {workspace} {description} {wideLayout} + {conversationKind} /> {/key} {/if} diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index f6aa0670c4..72982b828e 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -32,6 +32,8 @@ /** The flow's description, shown under the empty transcript's prompt. */ description?: string wideLayout?: boolean + /** What this surface's runs create: previews in the editor, deployed runs on the flow page. */ + conversationKind?: 'test' | 'deployed' } let { @@ -42,7 +44,8 @@ path, workspace = undefined, description = undefined, - wideLayout = false + wideLayout = false, + conversationKind = 'deployed' }: Props = $props() // Derive helperScript for dynamic inputs from schema @@ -150,10 +153,22 @@ { additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined), workspace: () => workspace, - sendDisabled: () => deploymentInProgress || !!modelGap + sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason } ) setChatViewHost(chatHost) + + // A chat of the other kind can be read from here but not added to: the server refuses a + // preview run into a deployed conversation and the reverse, so the composer says why first. + const wrongKindReason = $derived.by(() => { + const { conversationId, conversations } = chatHost.state + const open = conversations.find((c) => c.id === conversationId) + if (open?.isTest === undefined || open.isTest === (conversationKind === 'test')) + return undefined + return open.isTest + ? 'This chat was run from the flow editor. Start a new chat to continue here.' + : 'This chat belongs to the deployed flow. Start a new chat to test.' + }) onDestroy(() => chatHost.dispose()) // What the Configure-inputs modal asks for: every flow input the composer does not @@ -287,8 +302,10 @@ {emptyHint} footerSettings={modalSchema || showModelButton ? footerSettings : undefined} placeholder="Send a message to run the flow" - disabled={deploymentInProgress || !!modelGap} - disabledMessage={deploymentInProgress ? 'Deployment in progress' : (modelGap ?? '')} + disabled={deploymentInProgress || !!modelGap || !!wrongKindReason} + disabledMessage={deploymentInProgress + ? 'Deployment in progress' + : (modelGap ?? wrongKindReason ?? '')} loadPastChat={() => {}} deletePastChat={() => {}} saveAndClear={() => {}} diff --git a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte index 02c2581db1..49efbbc1fa 100644 --- a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte @@ -1,20 +1,46 @@ + +{#if on && !s3StorageConfigured} +

+ Without S3 storage on the workspace, memory is kept in the database, up to 100KB per memory. +

+{/if} +{#if legacyMessages} + +
+ + An earlier version of the editor saved these previous messages inside the memory setting, + and this agent still sends them. + + {#if historyOnStep} +
+ +
+ {/if} +
+
+{/if} +{#if legacyMemoryId} + +
+ + {historyOnStep + ? 'Fixed memory id generated when this flow was saved.' + : 'Fixed memory id saved with this agent.'} + Every run shares it unless the caller passes one. + +
+ {#if historyOnStep} + + {/if} + +
+
+
+{/if} +{#if legacyEquivalent} + +
+ + An earlier version of the editor saved this setting. It works the same as {on + ? 'On' + : 'Off'}. + +
+ +
+
+
+{/if} diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index fafe69ebb4..2e760bb88c 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -53,7 +53,9 @@ moduleId, opWorkspace = undefined, flowPath = '', - fromAgentEditor = false + fromAgentEditor = false, + chatInputEnabled = false, + linkedMemory = $bindable() }: { agent: string | undefined inputTransforms: Record @@ -71,6 +73,9 @@ // 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 + chatInputEnabled?: boolean + // The linked agent's memory once its config has loaded, for the step's history inputs. + linkedMemory?: { memory: unknown } | undefined } = $props() let ws = $derived(opWorkspace ?? $workspaceStore) @@ -211,6 +216,9 @@ let linkedInfo = $derived( loadedInfo?.ws === ws && loadedInfo?.path === agent ? loadedInfo : undefined ) + $effect(() => { + linkedMemory = linkedInfo ? { memory: linkedInfo.config?.memory } : undefined + }) let inheritedTools = $derived(linkedInfo?.tools ?? []) let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config)) let providerPath = $derived(linkedInfo?.providerPath) @@ -312,6 +320,20 @@ // saved without a complete one fails on every linked run. Block saving when the provider is // computed/connected (only a static value can be captured into the resource) or when the static // value is incomplete (a fresh step defaults to empty resource/model, which is still static). + // A saved agent never carries a memory id, so saving would drop the id this step's runs still fall + // back to and leave them without memory. The author picks what replaces it first. In chat mode the + // conversation id always won, so there the id was never read. + let legacyMemorySaveError = $derived.by(() => { + const memory = inputTransforms?.memory as + | { type?: string; value?: { kind?: string; context_length?: number; memory_id?: string } } + | undefined + const value = memory?.type === 'static' ? memory.value : undefined + if (chatInputEnabled || value?.kind !== 'auto' || !value.memory_id || !value.context_length) { + return undefined + } + return "This step still uses a fixed memory id from an earlier version. In Managed memory, choose Keep as memory id or Use the run's memory id, then save it as an agent." + }) + let providerSaveError = $derived.by(() => { const t = inputTransforms?.provider as | { type?: string; value?: { resource?: string; model?: string } } @@ -343,8 +365,8 @@ // the success toast that would otherwise bury the explanation. async function persist(path: string, description?: string): Promise { const dropped = nonStaticBrainKeys(inputTransforms) - if (providerSaveError) { - throw new Error(providerSaveError) + if (providerSaveError ?? legacyMemorySaveError) { + throw new Error(providerSaveError ?? legacyMemorySaveError) } if (dropped.length > 0) { sendUserToast( @@ -355,6 +377,12 @@ // Tool inputs are saved verbatim: the agent carries its tools' default bindings (static, AI or // flow expressions) as authored. Host flows override per-step via tool_inputs, never here. const value = inputTransformsToAgentConfig(inputTransforms, tools) + // An id an older editor baked into this step names the flow's memory. The agent is shared by + // every step linking it, and each of those takes its memory id from its own run. + if (value.memory && typeof value.memory === 'object' && 'memory_id' in value.memory) { + const { memory_id: _, ...memory } = value.memory as Record + value.memory = memory + } // The editor stays live during the requests below, so remember what linking would discard: // 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. @@ -691,9 +719,9 @@ size="sm" /> - {#if providerSaveError} + {#if providerSaveError ?? legacyMemorySaveError}

- {providerSaveError} + {providerSaveError ?? legacyMemorySaveError}

{/if}
@@ -701,7 +729,10 @@
{/if} diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index 145ecce9ea..83b2065fcd 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -51,6 +51,7 @@ import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import { AI_AGENT_SCHEMA } from '../flowInfers' + import { AGENT_HISTORY_KEYS, DEFAULT_AGENT_MEMORY, keepsManagedMemory } from '../agentFormFields' import { nextId } from '../flowModuleNextId' import { fetchAgentWithDraft, normalizeAgentRef } from '../linkedAgentDrafts' import type { AIAgentConfig } from '../agentResourceUtils' @@ -567,7 +568,7 @@ return } const missing: string[] = [] - if (!args.memory || (args.memory as { kind?: string }).kind === 'off') missing.push('memory') + if (!keepsManagedMemory(args.memory)) missing.push('memory') if (args.streaming !== true) missing.push('streaming') if (missing.length > 0) { sendUserToast( @@ -604,7 +605,7 @@ const aiAgentModules = flowStore.val.value.modules.filter((m) => m.value.type === 'aiagent') if (aiAgentModules.length === 0) { - // No AI agent exists, create one with context memory set to 10 + // No AI agent exists, so create one reading the chat's user message const aiAgentId = nextId(flowStateStore.val, flowStore.val) flowStore.val.value.modules = [ ...flowStore.val.value.modules, @@ -620,10 +621,10 @@ } else if (key === 'user_attachments') { accu[key] = { type: 'javascript', expr: `flow_input.${addAttachmentsInput()}` } } else if (key === 'memory') { - accu[key] = { type: 'static', value: { kind: 'auto', context_length: 10 } } + accu[key] = { type: 'static', value: structuredClone(DEFAULT_AGENT_MEMORY) } } else if (key === 'streaming') { accu[key] = { type: 'static', value: true } - } else { + } else if (!(AGENT_HISTORY_KEYS as readonly string[]).includes(key)) { accu[key] = { type: 'static', value: undefined @@ -637,7 +638,7 @@ } ] sendUserToast( - 'Chat mode enabled. AI agent created with user message and attachments inputs, context memory set to 10 and streaming turned on.', + 'Chat mode enabled. AI agent created with user message and attachments inputs, managed memory on and streaming turned on.', false ) } else if (aiAgentModules.length === 1) { @@ -679,22 +680,29 @@ // would be ignored. Those are checked on the agent itself below instead. const linkedAgent = value.agent ? normalizeAgentRef(value.agent) : undefined if (linkedAgent === undefined) { - // `off` is the first oneOf variant of the memory field, so a step added by hand - // carries it without anyone choosing it — and an agent that forgets every turn - // makes the chat a series of unrelated questions. Overwritten rather than left - // alone; the toast below says it happened. + // `off` is the first oneOf variant, so a step added by hand carries it without anyone + // choosing it, and an agent that forgets every turn makes the chat a series of unrelated + // questions: overwritten, and the toast says so. A step supplying its own history, as + // previous messages or a legacy manual list, has chosen it and is left alone. const memoryIsOff = (transform: InputTransform | undefined) => - transform?.type === 'static' && (transform.value as any)?.kind === 'off' + transform?.type === 'static' && + (transform.value as any)?.kind !== 'manual' && + !keepsManagedMemory(transform.value) + const messages = value.input_transforms['previous_messages'] + const suppliesHistory = + !isUnconfigured(messages) && + !(messages?.type === 'static' && !(messages.value as unknown[] | undefined)?.length) if ( - isUnconfigured(value.input_transforms['memory']) || - memoryIsOff(value.input_transforms['memory']) + !suppliesHistory && + (isUnconfigured(value.input_transforms['memory']) || + memoryIsOff(value.input_transforms['memory'])) ) { value.input_transforms['memory'] = { type: 'static', - value: { kind: 'auto', context_length: 10 } + value: structuredClone(DEFAULT_AGENT_MEMORY) } - applied.push('context memory set to 10') + applied.push('managed memory on') } // Without streaming the chat has no SSE to read, so a turn shows nothing — diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 29ee5cd32e..f51703dc22 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -291,6 +291,8 @@ } let inputTransformSchemaForm: { setArgs: (nargs: Record) => void } | undefined = $state(undefined) + // The linked agent's memory, which decides which history inputs the step offers. + let linkedAgentMemory: { memory: unknown } | undefined = $state(undefined) let reloadError: string | undefined = $state(undefined) async function reload(flowModule: FlowModule) { @@ -1154,6 +1156,8 @@ opWorkspace={opWs} flowPath={$pathStore} fromAgentEditor={agentEditorHost?.() != undefined} + bind:linkedMemory={linkedAgentMemory} + chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false} bind:agent={ () => flowModule.value.type === 'aiagent' @@ -1235,6 +1239,7 @@ chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false} workspace={opWs} visibilityKey={agentFieldsKey} + linkedMemory={agentLinked ? linkedAgentMemory : undefined} tools={agentLinked ? getLinkedAgentTools( linkedToolsScope(opWs, $pathStore), diff --git a/frontend/src/lib/components/flows/flowInfers.ts b/frontend/src/lib/components/flows/flowInfers.ts index 3c38682350..13f1ae3ef5 100644 --- a/frontend/src/lib/components/flows/flowInfers.ts +++ b/frontend/src/lib/components/flows/flowInfers.ts @@ -4,6 +4,25 @@ import type { Schema } from '$lib/common' import { emptySchema } from '$lib/utils' import type { FlowModule, InputTransform } from '$lib/gen' import { AGENT_FLOW_LOCAL_KEYS } from './agentResourceUtils' +import { AGENT_HISTORY_KEYS } from './agentFormFields' + +/** Display names for the memory field's options, so anything else naming the setting an author + * picked cannot drift from the button they see. */ +export const MEMORY_OPTION_LABELS: Record = { + off: 'Off', + window: 'On', + auto: 'On (legacy)', + manual: 'Previous messages (legacy)' +} + +export function memoryOptionLabel(memory: any): string | undefined { + // Managed memory that keeps no messages runs as off, and a note about what that state reads + // must say so. + if ((memory?.kind === 'window' || memory?.kind === 'auto') && !memory.context_length) { + return MEMORY_OPTION_LABELS.off + } + return memory?.kind ? MEMORY_OPTION_LABELS[memory.kind] : undefined +} export const AI_AGENT_SCHEMA: Schema = { $schema: 'https://json-schema.org/draft/2020-12/schema', @@ -39,108 +58,86 @@ export const AI_AGENT_SCHEMA: Schema = { }, memory: { type: 'object', + description: + 'Windmill stores the conversation and sends its last messages with each request.', + enumLabels: MEMORY_OPTION_LABELS, // Chat mode keys memory on the conversation, so a chat whose agent has memory off - // forgets every turn. Enabling chat mode sets `auto`; this keeps it there. A step + // forgets every turn. Enabling chat mode turns it on; this keeps it there. A step // sitting at `off` stays switchable, or a flow that reached that state before — // an agent added to an already-chat-enabled flow — would have no way out of it. lockOneOfWhenChatEnabled: "Chat mode keys this agent's history on the conversation, so memory stays on while it is enabled.", - description: 'History sent between the system message and the user message.', oneOf: [ { type: 'object', title: 'off', properties: { - kind: { - type: 'string', - enum: ['off'], - description: 'Disable conversation memory' - } + kind: { type: 'string', enum: ['off'] } } }, { type: 'object', - title: 'auto', + title: 'window', properties: { - kind: { - type: 'string', - enum: ['auto'], - default: 'auto', - description: 'Automatically manage conversation history' - }, + kind: { type: 'string', enum: ['window'] }, context_length: { type: 'number', - description: - 'Number of most recent messages to store and load. Set to 0 to disable memory.', - default: 5 - }, - memory_id: { - type: 'string', - format: 'uuid', - 'x-auto-generate': true, - description: - 'Custom memory identifier. Each unique ID maintains separate conversation history.', - hideWhenChatEnabled: true + title: 'Messages to keep', + description: 'Number of most recent messages to load and store. 0 turns memory off.', + default: 10 } }, - required: ['kind'], - 'x-no-s3-storage-workspace-warning': - 'When no S3 storage is configured in your workspace settings, memory will be stored in database, which implies a limit of 100KB per memory entry. If you need to store more messages, you should use S3 storage in your workspace settings.' - }, - { - type: 'object', - title: 'manual', - properties: { - kind: { - type: 'string', - enum: ['manual'], - description: - 'Manually provide conversation messages, bypassing automatic memory management' - }, - messages: { - type: 'array', - description: 'Array of conversation messages to use as history', - items: { - type: 'object', - properties: { - role: { - type: 'string', - enum: ['user', 'assistant', 'system'] - }, - content: { - type: 'string' - }, - tool_calls: { - type: 'array', - nullable: true, - items: { - type: 'object', - properties: { - id: { type: 'string' }, - type: { type: 'string' }, - function: { - type: 'object', - properties: { - name: { type: 'string' }, - arguments: { type: 'string' } - } - } - } - } - }, - tool_call_id: { - type: 'string', - nullable: true, - description: 'The ID of the tool call this message is responding to' + required: ['kind', 'context_length'] + } + ], + showExpr: "fields.output_type !== 'image'" + }, + memory_id: { + type: 'string', + description: + 'Names the memory this step reads and writes, overriding the memory id the run was started with. Read only while managed memory is on, and not at all by an older auto or manual memory.', + showExpr: "fields.output_type !== 'image'" + }, + previous_messages: { + type: 'array', + description: + 'History the flow supplies, sent before the user message. Read only while managed memory is off, and not at all by an older auto or manual memory.', + items: { + type: 'object', + properties: { + role: { + type: 'string', + enum: ['user', 'assistant', 'system'] + }, + content: { + type: 'string' + }, + tool_calls: { + type: 'array', + nullable: true, + items: { + type: 'object', + properties: { + id: { type: 'string' }, + type: { type: 'string' }, + function: { + type: 'object', + properties: { + name: { type: 'string' }, + arguments: { type: 'string' } } - }, - required: ['role'] + } } } }, - required: ['kind', 'messages'] - } - ], + tool_call_id: { + type: 'string', + nullable: true, + description: 'The ID of the tool call this message is responding to' + } + }, + required: ['role'] + }, showExpr: "fields.output_type !== 'image'" }, output_schema: { @@ -196,6 +193,8 @@ export const AI_AGENT_SCHEMA: Schema = { 'system_prompt', 'streaming', 'memory', + 'memory_id', + 'previous_messages', 'output_schema', 'user_attachments', 'enabled_tools', @@ -205,6 +204,48 @@ export const AI_AGENT_SCHEMA: Schema = { ] } +/** Memory shapes older editors wrote. The step form offers one only to a step that still holds it, + * since the one-of field rewrites a value that matches none of its options. No field carries a + * default: the form writes one into a missing field on open, and a missing count runs as off. */ +export const LEGACY_MEMORY_VARIANTS: Record = { + auto: { + type: 'object', + title: 'auto', + properties: { + kind: { type: 'string', enum: ['auto'] }, + context_length: { type: 'number', title: 'Messages to keep' }, + memory_id: { type: 'string', title: 'Fixed memory id' } + }, + required: ['kind'] + }, + manual: { + type: 'object', + title: 'manual', + properties: { + kind: { type: 'string', enum: ['manual'] }, + messages: { type: 'array', items: AI_AGENT_SCHEMA.properties?.previous_messages?.items } + }, + required: ['kind', 'messages'] + } +} + +/** The memory property to render for a value: a legacy kind is added as an option only while the + * value holds it. Otherwise the property itself is returned, which callers compare by identity to + * avoid rebuilding the step schema. */ +export function memoryPropertyFor(property: any, value: any, chatInputEnabled = false): any { + let legacy = value?.kind ? LEGACY_MEMORY_VARIANTS[value.kind] : undefined + if (!legacy || !property?.oneOf) return property + // The baked id field is offered only to a value saved with the key (by presence, not content, + // or clearing it to retype would remove the field mid-edit), and never in a chat flow, which + // runs on the conversation id and drops it on save; there the nested form removes the key on + // open, as the hidden field did before. + if (value.kind === 'auto' && (chatInputEnabled || !('memory_id' in value))) { + const { memory_id: _, ...properties } = legacy.properties + legacy = { ...legacy, properties } + } + return { ...property, oneOf: [...property.oneOf, legacy] } +} + function migrateAiAgentInputTransforms( inputTransforms: Record ): Record { @@ -306,10 +347,13 @@ export async function loadSchemaFromModule( : Object.keys(AI_AGENT_SCHEMA.properties ?? {}) return { input_transforms: keys.reduce((accu, key) => { - accu[key] = input_transforms[key] ?? { - type: 'static', - value: undefined - } + const transform = + input_transforms[key] ?? + // A present history input is the step's choice at runtime, so it gets no placeholder. + ((AGENT_HISTORY_KEYS as readonly string[]).includes(key) + ? undefined + : { type: 'static', value: undefined }) + if (transform) accu[key] = transform return accu }, {}), // A copy per step, never the shared constant: the form writes back into the property it diff --git a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts index 09ea3d7bd8..20bd675700 100644 --- a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts +++ b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts @@ -1,3 +1,4 @@ +import { DEFAULT_AGENT_MEMORY } from './agentFormFields' import type { Schema } from '$lib/common' import { ScriptService, @@ -197,7 +198,8 @@ export async function createBranchAll(id: string): Promise<[FlowModule, FlowModu export async function createAiAgent( id: string, - agentPath?: string + agentPath?: string, + chatInputEnabled = false ): Promise<[FlowModule, FlowModuleState]> { const storedConfig = loadStoredConfig() const providerValue = storedConfig ?? { kind: 'openai', resource: '', model: '' } @@ -214,7 +216,16 @@ export async function createAiAgent( ...(agentPath ? {} : { - provider: { type: 'static', value: providerValue } + provider: { type: 'static', value: providerValue }, + // A chat agent answers a conversation, so it remembers it from the start. + ...(chatInputEnabled + ? { + memory: { + type: 'static' as const, + value: structuredClone(DEFAULT_AGENT_MEMORY) + } + } + : {}) }), user_message: { type: 'static', value: undefined } } @@ -491,7 +502,11 @@ export async function createNewModule( } else if (kind == 'branchall') { ;[module, state] = await createBranchAll(module.id) } else if (kind == 'aiagent') { - ;[module, state] = await createAiAgent(module.id, agentPath) + ;[module, state] = await createAiAgent( + module.id, + agentPath, + flowStore.val.value?.chat_input_enabled ?? false + ) } else if (inlineScript) { const { language, kind, subkind, summary } = inlineScript ;[module, state] = await createInlineScriptModule(language, kind, subkind, module.id, summary) diff --git a/frontend/src/lib/components/flows/linkedAgentDrafts.test.ts b/frontend/src/lib/components/flows/linkedAgentDrafts.test.ts index fa72aaba5c..e9a84e771a 100644 --- a/frontend/src/lib/components/flows/linkedAgentDrafts.test.ts +++ b/frontend/src/lib/components/flows/linkedAgentDrafts.test.ts @@ -70,6 +70,28 @@ describe('inlineAgentDraft', () => { user_message: { type: 'static', value: 'hi' } }) }) + + // The worker never reads a history input from the resource, so a preview of the draft must not + // either: a draft carrying one would test against a memory the deployed step never sees. + it('never takes a history input from the draft', () => { + const inlined = inlineAgentDraft( + linkedStep({ + user_message: { type: 'static', value: 'hi' }, + memory_id: { type: 'static', value: 'cust-1' } + }), + { + memory: { kind: 'window', context_length: 10 }, + memory_id: 'from-the-draft', + previous_messages: [{ role: 'user', content: 'from the draft' }] + } as any + ) + + expect(inlined.input_transforms).toEqual({ + memory: { type: 'static', value: { kind: 'window', context_length: 10 } }, + user_message: { type: 'static', value: 'hi' }, + memory_id: { type: 'static', value: 'cust-1' } + }) + }) }) describe('inlineAgentDrafts', () => { diff --git a/frontend/src/lib/components/flows/linkedAgentDrafts.ts b/frontend/src/lib/components/flows/linkedAgentDrafts.ts index 65624d4429..4a9aef24b1 100644 --- a/frontend/src/lib/components/flows/linkedAgentDrafts.ts +++ b/frontend/src/lib/components/flows/linkedAgentDrafts.ts @@ -11,6 +11,7 @@ import { canWrite } from '$lib/utils' import type { UserExt } from '$lib/stores' import { dfs } from './dfs' import { flowLocalInputs, type AIAgentConfig } from './agentResourceUtils' +import { AGENT_HISTORY_KEYS } from './agentFormFields' import type { AgentResourceState } from './agentDraft.svelte' import type { AgentTool } from './agentToolUtils' @@ -196,17 +197,21 @@ type AiAgentValue = Extract * the step's own flow-local inputs kept on top. * * The overlay order is the worker's (`ai_executor.rs`): its linked branch interpolates the whole - * resource brain and only then writes `user_message`/`user_attachments` back from the step's own - * args. `tool_inputs` stays untouched — the worker overlays it onto the tools in both branches, so - * an inlined step keeps the host flow's tool bindings. + * resource brain and only then writes the flow-local inputs (`user_message`, `user_attachments`, + * `enabled_tools`, `memory_id`, `previous_messages`) back from the step's own args. `tool_inputs` + * stays untouched — the worker overlays it onto the tools in both branches, so an inlined step + * keeps the host flow's tool bindings. The worker never reads a history input from the resource, + * so one a draft happens to carry is left out here too. */ export function inlineAgentDraft(value: AiAgentValue, args: AIAgentConfig): AiAgentValue { const { agent: _agent, ...rest } = value + const brain = agentArgsToTransforms(args) + for (const key of AGENT_HISTORY_KEYS) delete brain[key] return { ...rest, tools: (args.tools ?? []) as AgentTool[], input_transforms: { - ...agentArgsToTransforms(args), + ...brain, ...flowLocalInputs(value.input_transforms as Record) } } as AiAgentValue diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index df27960dc7..2b479abceb 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -6,7 +6,6 @@ import { type Job, type RestartedFrom, type OpenFlow, - type MemoryConfig, type FlowValue, type Retry } from '$lib/gen' @@ -112,7 +111,6 @@ export function filteredContentForExport(flow: ExtendedOpenFlow) { } import { dfs as dfsApply } from './dfs' -import { randomUUID } from '$lib/utils/uuid' export function cleanFlow(flow: OpenFlow | any): OpenFlow & { tag?: string @@ -141,24 +139,8 @@ export function cleanFlow(flow: OpenFlow | any): OpenFlow & { if (mod.value.type == 'rawscript' && mod.value.assets?.length == 0) { mod.value.assets = undefined } - // Generate memory_id for AI agents with auto memory if not already set - // Only if chat input is not enabled, as otherwise memory id is based on conversation id - if (!newFlow.value.chat_input_enabled && mod.value.type === 'aiagent') { - const memoryTransform = mod.value.input_transforms?.memory - if (memoryTransform?.type === 'static' && memoryTransform.value) { - const memoryValue = memoryTransform.value as MemoryConfig - if ( - memoryValue.kind === 'auto' && - memoryValue.context_length && - memoryValue.context_length > 0 && - !memoryValue.memory_id - ) { - memoryTransform.value = { - ...memoryValue, - memory_id: randomUUID() - } - } - } + if (mod.value.type === 'aiagent') { + normalizeAgentHistory(mod.value.input_transforms, newFlow.value.chat_input_enabled ?? false) } }) if (newFlow.value.concurrency_key == '') { @@ -168,6 +150,43 @@ export function cleanFlow(flow: OpenFlow | any): OpenFlow & { return newFlow } +/** + * A baked legacy id is dropped in a chat flow, which runs on the conversation id; elsewhere it + * stays until the author converts it. Blank static history inputs read as unset and managed + * memory keeping no messages runs as off, so each is saved as what it runs as. + */ +export function normalizeAgentHistory( + inputTransforms: Record | undefined, + chatInputEnabled: boolean +) { + if (!inputTransforms) return + const memory = inputTransforms.memory + if ( + memory?.type === 'static' && + memory.value?.kind === 'window' && + !memory.value.context_length + ) { + memory.value = { kind: 'off' } + } + if ( + memory?.type === 'static' && + memory.value?.kind === 'auto' && + 'memory_id' in memory.value && + (chatInputEnabled || !String(memory.value.memory_id ?? '').trim()) + ) { + const { memory_id: _, ...policy } = memory.value + memory.value = policy + } + const memoryId = inputTransforms.memory_id + if (memoryId?.type === 'static' && !String(memoryId.value ?? '').trim()) { + delete inputTransforms.memory_id + } + const previousMessages = inputTransforms.previous_messages + if (previousMessages?.type === 'static' && !previousMessages.value?.length) { + delete inputTransforms.previous_messages + } +} + export function getDefaultExpr( key: string = 'myfield', previousModuleId: string | undefined, diff --git a/frontend/src/lib/components/flows/utils.test.ts b/frontend/src/lib/components/flows/utils.test.ts index 9a09d8044d..1a02e25efc 100644 --- a/frontend/src/lib/components/flows/utils.test.ts +++ b/frontend/src/lib/components/flows/utils.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { FlowValue } from '$lib/gen' -import { modulesWithRetryOrSleep } from './utils.svelte' +import { modulesWithRetryOrSleep, normalizeAgentHistory } from './utils.svelte' const constantRetry = { constant: { attempts: 1, seconds: 5 } } @@ -47,3 +47,53 @@ describe('modulesWithRetryOrSleep', () => { expect(modulesWithRetryOrSleep(flow)).toEqual([]) }) }) + +describe('normalizeAgentHistory', () => { + const legacy = () => ({ + memory: { + type: 'static', + value: { kind: 'auto', context_length: 10, memory_id: '0f5c3a8e-1d2b-4c6a-9e7f-3b8d2a1c4e6f' } + } + }) + + // Outside chat the baked id is still read for runs that pass none, and an older worker must keep + // accepting the step, so a save leaves it exactly as it was. + it('keeps a legacy baked memory id outside chat mode', () => { + const transforms = legacy() + normalizeAgentHistory(transforms, false) + expect(transforms).toEqual(legacy()) + }) + + it('drops a legacy baked memory id in chat mode, where it was never read', () => { + const transforms = legacy() + normalizeAgentHistory(transforms, true) + expect(transforms.memory.value).toEqual({ kind: 'auto', context_length: 10 }) + }) + + it('drops an empty baked memory id, which names no memory', () => { + const transforms = { + memory: { type: 'static', value: { kind: 'auto', context_length: 10, memory_id: '' } } + } + normalizeAgentHistory(transforms, false) + expect(transforms.memory.value).toEqual({ kind: 'auto', context_length: 10 }) + }) + + it('saves managed memory that keeps no messages as off, which is how it runs', () => { + for (const context_length of [0, null, undefined]) { + const transforms: Record = { + memory: { type: 'static', value: { kind: 'window', context_length } } + } + normalizeAgentHistory(transforms, false) + expect(transforms.memory.value).toEqual({ kind: 'off' }) + } + }) + + it('does not persist an empty static memory id or message list', () => { + const transforms: Record = { + memory_id: { type: 'static', value: ' ' }, + previous_messages: { type: 'static', value: [] } + } + normalizeAgentHistory(transforms, false) + expect(transforms).toEqual({}) + }) +}) diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 94f2491f2f..c9b87fef66 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -533,9 +533,30 @@ components: required: - kind + MemoryWindow: + type: object + description: | + Keeps the most recent messages of the memory named by the run's memory id (or the step's + `memory_id`). Without a memory id the agent runs without memory. + properties: + kind: + type: string + enum: + - window + context_length: + type: integer + description: Number of most recent messages to load and store. 0 turns memory off. + required: + - kind + - context_length + MemoryAuto: type: object - description: Automatic context management + deprecated: true + description: | + Deprecated, still read as it was written: the run's memory id, else the `memory_id` here. + The step's own `memory_id` is not read while this kind is set; switch the kind to `window` + to use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`. properties: kind: type: string @@ -568,7 +589,8 @@ components: MemoryManual: type: object - description: Explicit message history + deprecated: true + description: Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead. properties: kind: type: string @@ -583,15 +605,17 @@ components: - messages MemoryConfig: - description: Conversation memory configuration + description: Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`. oneOf: - $ref: '#/components/schemas/MemoryOff' + - $ref: '#/components/schemas/MemoryWindow' - $ref: '#/components/schemas/MemoryAuto' - $ref: '#/components/schemas/MemoryManual' discriminator: propertyName: kind mapping: 'off': '#/components/schemas/MemoryOff' + window: '#/components/schemas/MemoryWindow' auto: '#/components/schemas/MemoryAuto' manual: '#/components/schemas/MemoryManual' @@ -1041,7 +1065,10 @@ components: user_message: allOf: - $ref: '#/components/schemas/InputTransform' - description: The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax. + description: | + The user's prompt/message to the AI agent. Supports variable interpolation with + flow.input syntax. Required unless memory is off and `previous_messages` supplies + the prompt; image output always needs it. system_prompt: allOf: - $ref: '#/components/schemas/InputTransform' @@ -1054,6 +1081,24 @@ components: Streaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result memory: $ref: '#/components/schemas/MemoryTransform' + memory_id: + allOf: + - $ref: '#/components/schemas/InputTransform' + description: | + String. Names the memory this step reads and writes, overriding the memory id the run + was started with (the chat conversation, an app chat session or the `memory_id` run + parameter). Leave unset to use the run's memory id. A fixed value shares one memory + across every run; an expression such as `flow_input.customer_id` keeps one memory per + key. When it evaluates to an empty value the agent runs without memory. Read only + while `memory` is `window`: it is ignored when memory is off, and an older `auto` or + `manual` memory reads neither history input. + previous_messages: + allOf: + - $ref: '#/components/schemas/InputTransform' + description: | + Array of MemoryMessage. History supplied by the flow, sent between the system prompt + and the user message. Read only while `memory` is off or absent: managed memory + ignores it, and an older `auto` or `manual` memory reads neither history input. output_schema: allOf: - $ref: '#/components/schemas/InputTransform' @@ -1102,9 +1147,8 @@ components: Number. Limits how many times the agent can loop through reasoning and tool use. Range: 1-1000. # Only the flow-local inputs are always present: a step linked to an `ai_agent` resource - # (see `agent`) keeps just those and takes provider/output_type from the resource. - required: - - user_message + # (see `agent`) keeps just those and takes provider/output_type from the resource. Even + # `user_message` may be absent, when memory is off and `previous_messages` is the prompt. tools: type: array description: Array of tools the agent can use. The agent decides which tools to call based on the task @@ -1127,7 +1171,7 @@ components: Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain config (provider/model/system prompt/etc.) and tool set are resolved at runtime from that resource; the module's input_transforms then only carry the flow-local inputs - (user_message/user_attachments/enabled_tools). + (user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages). tool_inputs: type: object description: | diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index c204e05e68..9cf32d7bd1 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -135,7 +135,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, @@ -459,4 +459,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["user_message"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message/user_attachments/enabled_tools).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window`: it is ignored when memory is off, and an older `auto` or\n`manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index af7bc98067..109a581dde 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -166,7 +166,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, @@ -3281,7 +3281,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["user_message"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message/user_attachments/enabled_tools).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\`: it is ignored when memory is off, and an older \`auto\` or\\n\`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index da611bf168..f0f8b81a96 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -223,7 +223,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, @@ -547,4 +547,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}},"required":["user_message"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message/user_attachments/enabled_tools).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window`: it is ignored when memory is off, and an older `auto` or\n`manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index 5e922496bd..54441dd96b 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -135,7 +135,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, From 6e1ef93f329cb396ffc3df3304d592e8fa0e0e71 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 17 Sep 2026 14:55:45 +0200 Subject: [PATCH 17/22] feat: let test_run_flow name the conversation of a chat-mode test run (#11198) * feat: let test_run_flow name the conversation of a chat-mode test run Co-Authored-By: Claude Opus 5 (1M context) * fix: refuse a non-UUID conversation_id and mint chat test-run ids in one place Co-Authored-By: Claude Opus 5 (1M context) * test(ai-evals): add a chat-flow follow-up case and mock flow preview runs Co-Authored-By: Claude Opus 5 (1M context) * test: expect the conversation id argument on the manager's flow test bridge Co-Authored-By: Claude Opus 5 (1M context) * test(ai-evals): require the chat-flow follow-up runs to share one conversation id Co-Authored-By: Claude Opus 5 (1M context) * refactor: name the test_run_flow argument memory_id after the run parameter Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- ai_evals/README.md | 4 + ai_evals/adapters/frontend/mockBackend.ts | 23 +++++ .../adapters/frontend/vitestAdapter.test.ts | 9 ++ ai_evals/cases/global.yaml | 26 +++++ ai_evals/core/types.ts | 7 ++ ai_evals/core/validators.test.ts | 26 +++++ ai_evals/core/validators.ts | 17 ++++ .../global/initial/support_chat_flow.json | 59 +++++++++++ .../copilot/chat/AIChatManager.svelte.ts | 4 +- .../copilot/chat/AIChatManager.test.ts | 4 +- .../copilot/chat/flow/FlowAIChat.svelte | 3 +- .../copilot/chat/global/core.test.ts | 98 ++++++++++++++++++- .../components/copilot/chat/global/core.ts | 43 ++++++-- 13 files changed, 310 insertions(+), 13 deletions(-) create mode 100644 ai_evals/fixtures/frontend/global/initial/support_chat_flow.json diff --git a/ai_evals/README.md b/ai_evals/README.md index ee8f730fc3..603f28e5f9 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -175,6 +175,10 @@ the decrypted value, exactly as against a real backend. The chat's read path pas Seed a recognizable secret (the existing fixture uses `sk_live_do_not_leak_me`) and assert it via `valueExcludes` to catch a leak. +`toolExpect.toolCallArgs` entries support `sharedByAtLeast: `: at least `n` recorded +calls to that tool must carry the same non-blank string in the field. Use it for calls that +have to share an identifier, like two test runs of one chat conversation. + `toolExpect.toolCallArgs` entries additionally support `fieldMustBeAbsent: true`: no recorded call to that tool may pass the field at all (an explicit `null` counts as passing it). Use it for partial-update tools, where supplying a field the model could diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index fd66c1cbcc..d27bb4d69a 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -865,6 +865,29 @@ export function runBenchmarkFlowByPath(input: { }) } +/** + * Mirror `JobService.runFlowPreview` for benchmark workspaces, including the server's + * refusal of a chat-enabled flow run that names no conversation (`memory_id`). + */ +export function runBenchmarkFlowPreview(input: { + workspace: string + memoryId?: string + requestBody?: { path?: string; value?: { chat_input_enabled?: boolean }; args?: unknown } +}): string { + if (input.requestBody?.value?.chat_input_enabled && !input.memoryId) { + throw new Error('Bad request: memory_id is required for chat-enabled flows') + } + const args = (input.requestBody?.args ?? {}) as Record + return createBenchmarkCompletedJob({ + workspace: input.workspace, + jobKind: 'flowpreview', + success: true, + args, + result: { path: input.requestBody?.path, args, mocked: true }, + logs: 'Mock benchmark flow preview completed successfully.' + }) +} + export function previewBenchmarkSchedule(input: { requestBody?: Record }): Record { diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index bd6419da7c..c9e889b9a0 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -89,6 +89,7 @@ vi.mock('$lib/gen', async () => { previewBenchmarkSchedule, runBenchmarkDatatableSql, runBenchmarkFlowByPath, + runBenchmarkFlowPreview, runBenchmarkScriptByPath, runBenchmarkScriptPreview, updateBenchmarkDraft, @@ -295,6 +296,14 @@ vi.mock('$lib/gen', async () => { args: data.requestBody }) : actual.JobService.runScriptByPath(data), + runFlowPreview: async (data: { + workspace: string + memoryId?: string + requestBody?: { path?: string; value?: { chat_input_enabled?: boolean }; args?: unknown } + }) => + hasBenchmarkWorkspace(data.workspace) + ? runBenchmarkFlowPreview(data) + : actual.JobService.runFlowPreview(data as any), runFlowByPath: async (data: { workspace: string path: string diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index e8ec660f3b..45316b2810 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -2160,6 +2160,32 @@ - creates an AI draft of f/evals/global/process_invoice applying 8% tax - does not deploy or save the draft +- id: global-test38-chat-flow-follow-up-same-conversation + prompt: |- + I want to check that my support chat flow `f/evals/global/support_chat` remembers what was said. + Test it: first send "My name is Ada", then send "What is my name?" as a follow-up in the same chat. + initial: ai_evals/fixtures/frontend/global/initial/support_chat_flow.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - test_run_flow + # A chat flow's memory lives in its conversation, so a follow-up only reaches the first + # turn's history when both test runs name the same conversation. + toolCallArgs: + - tool: test_run_flow + field: memory_id + sharedByAtLeast: 2 + forbiddenToolsUsed: + - run_flow + - deploy_workspace_item + # The judge cannot observe runs; what this case guards is the conversation the runs share. + skipJudge: true + judgeChecklist: + - test-runs the chat flow twice, the second message as a follow-up in the first run's conversation + - id: global-undo-created-draft prompt: |- Create a draft Postgres resource at `u/admin/scratch_db` for host db.example.com port 5432, database `orders`, user `app`, and tell me what fields it ended up with. diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 4107dc53da..832a383a73 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -182,6 +182,13 @@ export interface ToolCallArgumentRule { * the point is that the model filled it in at all rather than what it said. */ nonEmpty?: boolean; + /** + * Existential over calls: at least this many recorded calls to `tool` carry the + * same non-blank string in `field`. Use when calls have to share an identifier — + * e.g. test runs that continue one conversation — while a retry with a rejected + * value in between is still acceptable. + */ + sharedByAtLeast?: number; /** * Universal over calls: no recorded call to `tool` may pass `field` at all. * For partial-update tools, where supplying a field the model could not have diff --git a/ai_evals/core/validators.test.ts b/ai_evals/core/validators.test.ts index 4b7891f67e..1f7974e34e 100644 --- a/ai_evals/core/validators.test.ts +++ b/ai_evals/core/validators.test.ts @@ -396,6 +396,32 @@ describe("validateToolExpectations", () => { expect(nonEmptyCheck?.details).toContain("blank on 1 of 2"); }); + it("requires sharedByAtLeast calls to carry one value, not merely a value each", () => { + const run = (ids: (string | undefined)[]) => + validateToolExpectations({ + run: { + success: true, + actual: {}, + assistantMessageCount: 1, + toolCallCount: ids.length, + toolsUsed: ["test_run_flow"], + toolCallDetails: ids.map((memory_id) => ({ + name: "test_run_flow", + arguments: { path: "f/chat", memory_id }, + })), + skillsInvoked: [], + }, + toolExpect: { + toolCallArgs: [{ tool: "test_run_flow", field: "memory_id", sharedByAtLeast: 2 }], + }, + }).find((c) => c.name.includes("is shared by at least 2 calls"))?.passed; + + expect(run(["a", "b"])).toBe(false); + expect(run(["a"])).toBe(false); + expect(run([undefined, undefined])).toBe(false); + expect(run(["rejected", "a", "a"])).toBe(true); + }); + it("passes nonEmpty when every call filled the field", () => { const checks = validateToolExpectations({ run: { diff --git a/ai_evals/core/validators.ts b/ai_evals/core/validators.ts index 132a9ff3d0..9864f33dcc 100644 --- a/ai_evals/core/validators.ts +++ b/ai_evals/core/validators.ts @@ -320,6 +320,23 @@ export function validateToolExpectations(input: { ); } + if (rule.sharedByAtLeast !== undefined) { + const counts = new Map(); + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + } + const mostShared = Math.max(0, ...counts.values()); + checks.push( + check( + `${rule.tool}.${rule.field} is shared by at least ${rule.sharedByAtLeast} calls`, + mostShared >= rule.sharedByAtLeast, + `most calls sharing one value: ${mostShared}; values: ${summarizeToolValues(values)}` + ) + ); + } + if (rule.fieldMustBeAbsent) { // Anything other than `undefined` was supplied — an explicit `null` is the // model passing the field, not omitting it. diff --git a/ai_evals/fixtures/frontend/global/initial/support_chat_flow.json b/ai_evals/fixtures/frontend/global/initial/support_chat_flow.json new file mode 100644 index 0000000000..0db4242ed1 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/support_chat_flow.json @@ -0,0 +1,59 @@ +{ + "workspace": { + "flows": [ + { + "path": "f/evals/global/support_chat", + "summary": "Support chat", + "description": "Answers customer questions in a chat, remembering earlier messages.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "user_message": { + "type": "string", + "description": "Message from user" + } + }, + "required": ["user_message"] + }, + "value": { + "chat_input_enabled": true, + "modules": [ + { + "id": "assistant", + "summary": "Support assistant", + "value": { + "type": "aiagent", + "tools": [], + "input_transforms": { + "provider": { + "type": "static", + "value": { + "kind": "anthropic", + "model": "claude-haiku-4-5-20251001", + "resource": "$res:f/evals/ai/anthropic" + } + }, + "user_message": { + "type": "javascript", + "expr": "flow_input.user_message" + }, + "system_prompt": { + "type": "static", + "value": "You are a friendly support assistant. Keep answers short." + }, + "memory": { + "type": "static", + "value": { "kind": "auto", "context_length": 10 } + }, + "streaming": { "type": "static", "value": true }, + "output_type": { "type": "static", "value": "text" } + } + } + } + ] + } + } + ] + } +} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 34a6b5f3f1..ec3f54ca92 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2440,8 +2440,8 @@ export class AIChatManager implements ChatViewHost { openArtifact: this.openArtifact } : {}), - testActiveFlow: async (storagePath: string, args?: Record) => - this.flowEditorFor(storagePath)?.testFlow(args), + testActiveFlow: async (storagePath: string, args?: Record, memoryId?: string) => + this.flowEditorFor(storagePath)?.testFlow(args, memoryId), getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined), attachedFiles: this.attachedFiles, getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '', diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 7142646d90..35ac65253c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -858,7 +858,9 @@ describe('AIChatManager autonomy mode', () => { const jobId = await manager.helpers.testActiveFlow('u/admin/live_flow', { name: 'Ada' }) expect(jobId).toBe('job-flow-preview') - expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }) + // Second argument is the chat-mode memory id, which only `test_run_flow`'s + // own `memory_id` supplies — never the session id. + expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }, undefined) // A session chat resolves an editor by its storage path, so it never names one. expect(manager.flowAiChatHelpers).toBeUndefined() }) diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index c6bed6988c..9744085567 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -4,6 +4,7 @@ import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types' import type { InputTransform } from '$lib/gen' import type { FlowAIChatHelpers } from './core' + import { chatMemoryId } from '../global/core' import { createInlineScriptSession } from './inlineScriptsUtils' import { loadSchemaFromModule } from '$lib/components/flows/flowInfers' import { getAiChatManager } from '../aiChatManagerContext' @@ -173,7 +174,7 @@ previewArgs.val = args } // Call the UI test function which opens preview panel - return await onTestFlow?.(conversationId) + return await onTestFlow?.(conversationId ?? chatMemoryId(flowStore.val.value)) }, getLintErrors: async (moduleId: string): Promise => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 822b36135c..825fdd34c3 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -5240,13 +5240,101 @@ describe('global AI tools', () => { ) // What the form submitted, not what the model proposed: the editor runs the flow, but - // the arguments are the user's. - expect(testActiveFlow).toHaveBeenCalledWith('u/admin/live_flow_storage', { name: 'Grace' }) + // the arguments are the user's. The third argument is the chat-mode memory id, + // which only `test_run_flow`'s own `memory_id` supplies. + expect(testActiveFlow).toHaveBeenCalledWith( + 'u/admin/live_flow_storage', + { name: 'Grace' }, + undefined + ) expect(FlowService.getFlowByPath).not.toHaveBeenCalled() expect(JobService.runFlowPreview).not.toHaveBeenCalled() expect(result).toContain('Result (SUCCESS)') }) + // A chat flow only shows its memory across turns, so the model has to be able to name + // the conversation it is continuing rather than getting a fresh one every call. + it('test_run_flow passes the memory id it was given to the live editor hook', async () => { + seedBackendDraft( + 'flow', + '', + { + path: 'u/admin/live_chat_flow', + summary: 'Live chat flow', + value: { modules: [{ id: 'live_step', value: { type: 'identity' } }] }, + schema: { type: 'object', properties: { user_message: { type: 'string' } } }, + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'flow', + storagePath: '', + effectivePath: 'u/admin/live_chat_flow' + }) + const testActiveFlow = vi.fn(async () => 'job-live-chat') + + await withCompletedTestJob(() => + callGlobalTool( + 'test_run_flow', + { + path: 'u/admin/live_chat_flow', + args: { user_message: 'hi' }, + memory_id: '550e8400-e29b-41d4-a716-446655440000' + }, + toolCallbacks, + { testActiveFlow } + ) + ) + + expect(testActiveFlow).toHaveBeenCalledWith( + '', + { user_message: 'hi' }, + '550e8400-e29b-41d4-a716-446655440000' + ) + }) + + it('test_run_flow gives a chat-enabled flow a conversation when none is named', async () => { + const value = { + modules: [{ id: 'chat_step', value: { type: 'identity' } }], + chat_input_enabled: true + } + seedBackendDraft( + 'flow', + 'u/admin/chat_preview', + { + path: 'u/admin/chat_preview', + summary: 'Chat preview', + value, + schema: { type: 'object', properties: { user_message: { type: 'string' } } }, + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + }, + { workspace: WORKSPACE } + ) + + await withCompletedTestJob(() => + callGlobalTool('test_run_flow', { + path: 'u/admin/chat_preview', + args: { user_message: 'hi' } + }) + ) + + expect(JobService.runFlowPreview).toHaveBeenCalledWith({ + workspace: WORKSPACE, + memoryId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ), + requestBody: { path: 'u/admin/chat_preview', value, args: { user_message: 'hi' } } + }) + }) + it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => { seedBackendDraft( 'flow', @@ -5283,7 +5371,11 @@ describe('global AI tools', () => { ) ) - expect(testActiveFlow).toHaveBeenCalledWith('u/admin/live_flow_fallback', { name: 'Ada' }) + expect(testActiveFlow).toHaveBeenCalledWith( + 'u/admin/live_flow_fallback', + { name: 'Ada' }, + undefined + ) expect(FlowService.getFlowByPath).not.toHaveBeenCalled() expect(JobService.runFlowPreview).toHaveBeenCalledWith({ workspace: WORKSPACE, diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index f1dcc0ef0a..b9a2cc7a91 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -1,3 +1,4 @@ +import { randomUUID } from '$lib/utils/uuid' import { AppService, AzureTriggerService, @@ -934,6 +935,17 @@ const runScriptToolDef = createToolDef( const testRunFlowSchema = z.object({ path: z.string().describe('Workspace path of the flow to test.'), args: testRunArgsSchema, + // A refinement rather than z.guid(): that emits `format`/`pattern` into the tool schema, + // which some providers' function-schema subsets reject. + memory_id: z + .string() + .refine((value) => z.guid().safeParse(value).success, { + message: 'memory_id must be a UUID' + }) + .optional() + .describe( + 'Chat-mode flows only. A UUID naming the conversation this turn belongs to, whose memory the agent steps read: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.' + ), background: backgroundArgSchema, wait_seconds: waitSecondsArgSchema }) @@ -4391,8 +4403,13 @@ type WriteDraftCtx = { export type SessionToolHelpers = { sessionId?: string } export type GlobalToolHelpers = SessionToolHelpers & { - /** Runs the flow editor mounted on `storagePath`, if one is. */ - testActiveFlow?: (storagePath: string, args?: Record) => Promise + /** Runs the flow editor mounted on `storagePath`, if one is. `memoryId` names the + * chat-mode conversation the turn belongs to. */ + testActiveFlow?: ( + storagePath: string, + args?: Record, + memoryId?: string + ) => Promise attachedFiles?: AttachedFilesStore // Read/write the user-level Global instructions. `setUserInstructions` persists the // value and rebuilds the system message so the change applies on the next chat-loop @@ -4429,13 +4446,15 @@ function operatingWorkspaceFromHelpers(helpers: unknown): string | undefined { function liveFlowTestHookFromCtx( ctx: { workspace: string; helpers?: unknown }, path: string -): ((args?: Record) => Promise) | undefined { +): ((args?: Record, memoryId?: string) => Promise) | undefined { const activeEditor = getActiveGlobalEditorContext(ctx.workspace) if (activeEditor?.type !== 'flow' || activeEditor.path !== path) { return undefined } const testActiveFlow = (ctx.helpers as GlobalToolHelpers | undefined)?.testActiveFlow - return testActiveFlow && ((args) => testActiveFlow(activeEditor.storagePath, args)) + return ( + testActiveFlow && ((args, memoryId) => testActiveFlow(activeEditor.storagePath, args, memoryId)) + ) } export type OpenPreviewHandler = (req: { @@ -5450,6 +5469,16 @@ function flowDraftValueForPreview(flowDraft: FlowDraftValue): FlowValue { return flowDraftAsEditableInput(flowDraft).value } +/** + * The conversation a test run of a chat-enabled flow belongs to. The server refuses such a + * run without one, and it is a query parameter rather than a flow argument, so there is no + * way for the caller to supply it through `args`. A fresh id each time is the right default: + * a test run is its own conversation, not a turn appended to one someone is reading. + */ +export function chatMemoryId(value: FlowValue): string | undefined { + return value.chat_input_enabled ? randomUUID() : undefined +} + async function loadScriptForFlowStep( moduleValue: { path: string; hash?: string }, workspace: string @@ -5915,15 +5944,17 @@ async function testRunFlowByPath( // An open editor runs its own in-memory flow and paints the run in its graph. // Resolved here rather than before the form: the form waits as long as the user // does, and the editor on screen when they press Run is the one it belongs in. - const jobId = await liveFlowTestHookFromCtx(ctx, args.path)?.(submitted) + const jobId = await liveFlowTestHookFromCtx(ctx, args.path)?.(submitted, args.memory_id) if (jobId) { return jobId } + const value = flowDraftValueForPreview(flow.flow) return JobService.runFlowPreview({ workspace, + memoryId: args.memory_id ?? chatMemoryId(value), requestBody: { path: args.path, - value: flowDraftValueForPreview(flow.flow), + value, args: submitted } }) From a571117f3fd2cef14c920770645c60ee358fdfdd Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 17 Sep 2026 15:35:18 +0200 Subject: [PATCH 18/22] feat: store mcp tool call, result and reasoning on flow conversation rows (#11176) * feat: store mcp tool call, result and reasoning on flow conversation rows Co-Authored-By: Claude Fable 5.1 * fix: keep the failure reason and web search citations on tool rows Co-Authored-By: Claude Fable 5.1 * fix: persist a structured answer as its own row and keep reasoning-only rows from closing a turn Co-Authored-By: Claude Fable 5.1 * fix: keep a failed Windmill tool's error on its conversation row Co-Authored-By: Claude Fable 5.1 * fix: let a structured answer row claim its streamed thinking Co-Authored-By: Claude Fable 5.1 * fix: scope a structured answer's claim to its own turn and document the row fields as stored Co-Authored-By: Claude Fable 5.1 * fix: claim a textless assistant row only within the newest turn Co-Authored-By: Claude Fable 5.1 * refactor: store the thinking that led to a tool call on the tool row Co-Authored-By: Claude Opus 5 (1M context) * fix: keep tool calls in stream order and scope a structured answer to its turn Co-Authored-By: Claude Opus 5 (1M context) * feat: store the files a user message carried as object-storage references on its row Co-Authored-By: Claude Opus 5 (1M context) * feat(chat-sdk): read a message's attachments and build their download URL Co-Authored-By: Claude Opus 5 (1M context) * feat(chat-sdk): carry a loaded user message's attachments in AI SDK metadata Co-Authored-By: Claude Opus 5 (1M context) * feat: store the model's call and what it got back on every tool row Co-Authored-By: Claude Opus 5 (1M context) * fix: pass no extras in the orphaned-conversation test's message insert Co-Authored-By: Claude Opus 5 (1M context) * fix(chat-sdk): keep a stored JSON null tool result instead of the row text Co-Authored-By: Claude Opus 5 (1M context) * fix: label a tool call the turn finished without as not finished Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a structured answer's streamed call out of the did-not-finish label Co-Authored-By: Claude Opus 5 (1M context) * fix: write a turn's conversation rows in order and describe stored tool calls Co-Authored-By: Claude Opus 5 (1M context) * fix: await the image answer row like the agent loop's other rows Co-Authored-By: Claude Opus 5 (1M context) * fix(chat-sdk): place a row nothing streamed by its sequence, not at the end Co-Authored-By: Claude Opus 5 (1M context) * fix(chat-sdk): place only tool rows by sequence, keep a closing row last Co-Authored-By: Claude Opus 5 (1M context) * fix(flow chat): show a failed tool's error, and no Retry on a running or stopped turn A failed tool card showed the row label in place of the error the row now stores. The live tool result now reports failures, so a failed call briefly flagged a running turn as failed; a stopped turn, whose last row is the failed tool or the cancelled flow's failure, offered Retry too. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Fable 5.1 --- ...a1b26f340ccf574b9fa07641fe88b2d2987c.json} | 10 +- ...e94c5042fd5e6351fea139a66ecb1fb812ab.json} | 30 ++- ...8627be1dac6fbe0097735a52f47c173f51c9.json} | 30 ++- backend/Cargo.lock | 1 + ...ow_conversation_message_tool_call.down.sql | 3 + ...flow_conversation_message_tool_call.up.sql | 12 + ..._conversation_message_attachments.down.sql | 1 + ...ow_conversation_message_attachments.up.sql | 4 + backend/summarized_schema.txt | 2 +- backend/tests/v2_job_delete_orphans.rs | 1 + backend/windmill-ai/Cargo.toml | 1 + backend/windmill-ai/src/providers/bedrock.rs | 8 +- backend/windmill-ai/src/sse.rs | 19 +- .../src/lib.rs | 19 +- backend/windmill-api-jobs/src/execution.rs | 12 +- backend/windmill-api/openapi.yaml | 44 ++++ backend/windmill-api/src/jobs.rs | 1 + .../windmill-common/src/flow_conversations.rs | 118 +++++++++- backend/windmill-worker/src/ai/tools.rs | 172 +++++++++++--- backend/windmill-worker/src/ai/utils.rs | 4 +- backend/windmill-worker/src/ai_executor.rs | 190 +++++++++------ backend/windmill-worker/src/worker_flow.rs | 1 + chat-sdk/README.md | 6 +- chat-sdk/src/ai-sdk.ts | 45 +++- chat-sdk/src/api.ts | 21 +- chat-sdk/src/assistant-ui.ts | 5 +- chat-sdk/src/chat.ts | 62 ++++- chat-sdk/src/index.ts | 1 + chat-sdk/src/types.ts | 6 +- chat-sdk/test/ai-sdk.test.ts | 29 ++- chat-sdk/test/api.test.ts | 14 ++ chat-sdk/test/assistant-ui.test.ts | 7 + chat-sdk/test/chat.test.ts | 221 ++++++++++++++++++ .../conversations/flowChatViewHost.svelte.ts | 141 ++++++++--- .../conversations/flowChatViewHost.test.ts | 150 ++++++++++++ 35 files changed, 1202 insertions(+), 189 deletions(-) rename backend/.sqlx/{query-b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1.json => query-12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c.json} (61%) rename backend/.sqlx/{query-e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082.json => query-a4a823f70b3dbe6aaf4a61c98345e94c5042fd5e6351fea139a66ecb1fb812ab.json} (59%) rename backend/.sqlx/{query-1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051.json => query-d6fa78c43b6c5f8040d7bccb29ad8627be1dac6fbe0097735a52f47c173f51c9.json} (65%) create mode 100644 backend/migrations/20260916144653_flow_conversation_message_tool_call.down.sql create mode 100644 backend/migrations/20260916144653_flow_conversation_message_tool_call.up.sql create mode 100644 backend/migrations/20260917074040_flow_conversation_message_attachments.down.sql create mode 100644 backend/migrations/20260917074040_flow_conversation_message_attachments.up.sql create mode 100644 chat-sdk/test/api.test.ts diff --git a/backend/.sqlx/query-b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1.json b/backend/.sqlx/query-12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c.json similarity index 61% rename from backend/.sqlx/query-b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1.json rename to backend/.sqlx/query-12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c.json index 1818efc0c0..4c120699aa 100644 --- a/backend/.sqlx/query-b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1.json +++ b/backend/.sqlx/query-12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)\n VALUES ($1, $2, $3, $4, $5, $6)", + "query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning, attachments)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", "describe": { "columns": [], "parameters": { @@ -21,10 +21,14 @@ "Text", "Uuid", "Varchar", - "Bool" + "Bool", + "Text", + "Text", + "Text", + "Jsonb" ] }, "nullable": [] }, - "hash": "b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1" + "hash": "12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c" } diff --git a/backend/.sqlx/query-e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082.json b/backend/.sqlx/query-a4a823f70b3dbe6aaf4a61c98345e94c5042fd5e6351fea139a66ecb1fb812ab.json similarity index 59% rename from backend/.sqlx/query-e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082.json rename to backend/.sqlx/query-a4a823f70b3dbe6aaf4a61c98345e94c5042fd5e6351fea139a66ecb1fb812ab.json index a3374d6cdf..9ec8c1e2d5 100644 --- a/backend/.sqlx/query-e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082.json +++ b/backend/.sqlx/query-a4a823f70b3dbe6aaf4a61c98345e94c5042fd5e6351fea139a66ecb1fb812ab.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ", + "query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning, attachments\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning, attachments\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ", "describe": { "columns": [ { @@ -58,6 +58,26 @@ "ordinal": 8, "name": "success", "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "tool_arguments", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "tool_result", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "reasoning", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "attachments", + "type_info": "Jsonb" } ], "parameters": { @@ -76,8 +96,12 @@ false, false, true, - false + false, + true, + true, + true, + true ] }, - "hash": "e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082" + "hash": "a4a823f70b3dbe6aaf4a61c98345e94c5042fd5e6351fea139a66ecb1fb812ab" } diff --git a/backend/.sqlx/query-1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051.json b/backend/.sqlx/query-d6fa78c43b6c5f8040d7bccb29ad8627be1dac6fbe0097735a52f47c173f51c9.json similarity index 65% rename from backend/.sqlx/query-1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051.json rename to backend/.sqlx/query-d6fa78c43b6c5f8040d7bccb29ad8627be1dac6fbe0097735a52f47c173f51c9.json index fb27bd9446..baf0e51d4f 100644 --- a/backend/.sqlx/query-1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051.json +++ b/backend/.sqlx/query-d6fa78c43b6c5f8040d7bccb29ad8627be1dac6fbe0097735a52f47c173f51c9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ", + "query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning, attachments\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ", "describe": { "columns": [ { @@ -58,6 +58,26 @@ "ordinal": 8, "name": "success", "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "tool_arguments", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "tool_result", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "reasoning", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "attachments", + "type_info": "Jsonb" } ], "parameters": { @@ -76,8 +96,12 @@ false, false, true, - false + false, + true, + true, + true, + true ] }, - "hash": "1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051" + "hash": "d6fa78c43b6c5f8040d7bccb29ad8627be1dac6fbe0097735a52f47c173f51c9" } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 67e37830fb..b2b9d65c97 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14896,6 +14896,7 @@ dependencies = [ "eventsource-stream", "futures", "http 1.5.0", + "indexmap 2.14.2", "lazy_static", "mime_guess", "reqwest 0.13.5", diff --git a/backend/migrations/20260916144653_flow_conversation_message_tool_call.down.sql b/backend/migrations/20260916144653_flow_conversation_message_tool_call.down.sql new file mode 100644 index 0000000000..56f20ba722 --- /dev/null +++ b/backend/migrations/20260916144653_flow_conversation_message_tool_call.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE flow_conversation_message DROP COLUMN tool_arguments; +ALTER TABLE flow_conversation_message DROP COLUMN tool_result; +ALTER TABLE flow_conversation_message DROP COLUMN reasoning; diff --git a/backend/migrations/20260916144653_flow_conversation_message_tool_call.up.sql b/backend/migrations/20260916144653_flow_conversation_message_tool_call.up.sql new file mode 100644 index 0000000000..a6f7822246 --- /dev/null +++ b/backend/migrations/20260916144653_flow_conversation_message_tool_call.up.sql @@ -0,0 +1,12 @@ +-- A chat is rebuilt from its rows without reading jobs, so every tool row carries its call: +-- the arguments the model wrote and the text the model got back, or what the call failed +-- with. A script or flow tool's job holds the args its input transforms produced, not the +-- model's; an MCP tool runs inside the agent's job, whose result lists every call of the +-- turn with nothing tying one to a row. A provider-native web search carries only its +-- citations, the provider never returning the query. +ALTER TABLE flow_conversation_message ADD COLUMN tool_arguments TEXT; +ALTER TABLE flow_conversation_message ADD COLUMN tool_result TEXT; + +-- The thinking behind this row. The agent job keeps the turn's thinking as one string; +-- the rows keep it per iteration, next to the answer or tool call it led to. +ALTER TABLE flow_conversation_message ADD COLUMN reasoning TEXT; diff --git a/backend/migrations/20260917074040_flow_conversation_message_attachments.down.sql b/backend/migrations/20260917074040_flow_conversation_message_attachments.down.sql new file mode 100644 index 0000000000..01d81a9719 --- /dev/null +++ b/backend/migrations/20260917074040_flow_conversation_message_attachments.down.sql @@ -0,0 +1 @@ +ALTER TABLE flow_conversation_message DROP COLUMN attachments; diff --git a/backend/migrations/20260917074040_flow_conversation_message_attachments.up.sql b/backend/migrations/20260917074040_flow_conversation_message_attachments.up.sql new file mode 100644 index 0000000000..dec5365f89 --- /dev/null +++ b/backend/migrations/20260917074040_flow_conversation_message_attachments.up.sql @@ -0,0 +1,4 @@ +-- The files a user message carried, as object-storage references: `[{input, s3, storage?, +-- filename?}]`. Only references, never file bytes and never a presigned URL, so a +-- transcript can show a message's files without reading its run's args. +ALTER TABLE flow_conversation_message ADD COLUMN attachments JSONB; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index cab1d7fe68..e27cb2893c 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -101,7 +101,7 @@ flow: workspace_id(char), path(char), summary(text), description(text), value(js FK: (workspace_id) -> workspace(id) flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char), is_test(bool) FK: (workspace_id) -> workspace(id) -flow_conversation_message: id(uuid), conversation_id(uuid), message_type(message_type), content(text), job_id(uuid), created_at(ts), created_seq(int8), step_name(char), success(bool) +flow_conversation_message: id(uuid), conversation_id(uuid), message_type(message_type), content(text), job_id(uuid), created_at(ts), created_seq(int8), step_name(char), success(bool), tool_arguments(text), tool_result(text), reasoning(text), attachments(jsonb) FK: (conversation_id) -> flow_conversation(id) | (job_id) -> v2_job(id) flow_iterator_data: job_id(uuid), itered(jsonb) flow_node: id(bigint), workspace_id(char), hash(bigint), path(char), lock(text), code(text), flow(jsonb), hash_v2(char(64)) diff --git a/backend/tests/v2_job_delete_orphans.rs b/backend/tests/v2_job_delete_orphans.rs index b45fa4201f..2b11deab77 100644 --- a/backend/tests/v2_job_delete_orphans.rs +++ b/backend/tests/v2_job_delete_orphans.rs @@ -269,6 +269,7 @@ async fn test_new_turns_wait_for_conversation_cleanup_and_recreate( windmill_common::flow_conversations::MessageType::User, None, true, + None, ) .await?; tx.commit().await?; diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index 0276240246..98ea87a4e3 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -23,6 +23,7 @@ async-trait.workspace = true async-stream.workspace = true base64.workspace = true bytes.workspace = true +indexmap.workspace = true eventsource-stream.workspace = true futures.workspace = true http.workspace = true diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index adee6e5053..412e057396 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -1074,7 +1074,8 @@ impl BedrockQueryBuilder { let mut accumulated_text = String::new(); let mut events_str = String::new(); - let mut accumulated_tool_calls: HashMap = HashMap::new(); + let mut accumulated_tool_calls: indexmap::IndexMap = + indexmap::IndexMap::new(); let mut current_tool_use_id: Option = None; let mut usage: Option = None; // Claude reasoning block for the turn (only populated when thinking is on), @@ -1263,7 +1264,10 @@ mod tests { // recovers the uncached share by subtracting the details back out. assert_eq!(usage["usage"]["prompt_tokens"], 1010); assert_eq!(usage["usage"]["completion_tokens"], 7); - assert_eq!(usage["usage"]["prompt_tokens_details"]["cached_tokens"], 900); + assert_eq!( + usage["usage"]["prompt_tokens_details"]["cached_tokens"], + 900 + ); assert_eq!( usage["usage"]["prompt_tokens_details"]["cache_write_tokens"], 100 diff --git a/backend/windmill-ai/src/sse.rs b/backend/windmill-ai/src/sse.rs index 054baeb5de..57acd66738 100644 --- a/backend/windmill-ai/src/sse.rs +++ b/backend/windmill-ai/src/sse.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use eventsource_stream::Eventsource; +use indexmap::IndexMap; use reqwest::Response; use serde::Deserialize; use tokio_stream::StreamExt; @@ -137,7 +138,9 @@ pub struct OpenAISSEParser { pub accumulated_content: String, /// The thinking streamed before the answer, kept so it can be stored with it. pub accumulated_reasoning: String, - pub accumulated_tool_calls: HashMap, + // Insertion-ordered in every parser: tool calls run and are persisted in the order the + // stream showed them, and a chat attaches a round's thinking to its first call. + pub accumulated_tool_calls: IndexMap, pub events_str: String, pub stream_event_processor: Box, /// Token usage from final chunk (when stream_options.include_usage is true) @@ -149,7 +152,7 @@ impl OpenAISSEParser { Self { accumulated_content: String::new(), accumulated_reasoning: String::new(), - accumulated_tool_calls: HashMap::new(), + accumulated_tool_calls: IndexMap::new(), events_str: String::new(), stream_event_processor, usage: None, @@ -359,7 +362,7 @@ pub struct AnthropicSSEParser { pub accumulated_content: String, /// The thinking streamed before the answer, kept so it can be stored with it. pub accumulated_reasoning: String, - pub accumulated_tool_calls: HashMap, + pub accumulated_tool_calls: IndexMap, pub events_str: String, pub stream_event_processor: Box, /// Track content block types by index @@ -382,7 +385,7 @@ impl AnthropicSSEParser { Self { accumulated_content: String::new(), accumulated_reasoning: String::new(), - accumulated_tool_calls: HashMap::new(), + accumulated_tool_calls: IndexMap::new(), events_str: String::new(), stream_event_processor, content_blocks: HashMap::new(), @@ -601,7 +604,7 @@ pub struct GeminiSSEParser { pub accumulated_content: String, /// The thinking streamed before the answer, kept so it can be stored with it. pub accumulated_reasoning: String, - pub accumulated_tool_calls: HashMap, + pub accumulated_tool_calls: IndexMap, pub events_str: String, pub stream_event_processor: Box, tool_call_index: i64, @@ -615,7 +618,7 @@ impl GeminiSSEParser { Self { accumulated_content: String::new(), accumulated_reasoning: String::new(), - accumulated_tool_calls: HashMap::new(), + accumulated_tool_calls: IndexMap::new(), events_str: String::new(), stream_event_processor, tool_call_index: 0, @@ -833,7 +836,7 @@ pub struct OpenAIResponsesSSEParser { pub accumulated_content: String, /// The reasoning summary streamed before the answer, kept so it can be stored with it. pub accumulated_reasoning: String, - pub accumulated_tool_calls: HashMap, + pub accumulated_tool_calls: IndexMap, /// Maps item_id -> (name, call_id) for function calls tool_call_metadata: HashMap, /// Maps item_id -> accumulated arguments @@ -855,7 +858,7 @@ impl OpenAIResponsesSSEParser { Self { accumulated_content: String::new(), accumulated_reasoning: String::new(), - accumulated_tool_calls: HashMap::new(), + accumulated_tool_calls: IndexMap::new(), tool_call_metadata: HashMap::new(), tool_call_arguments: HashMap::new(), events_str: String::new(), diff --git a/backend/windmill-api-flow-conversations/src/lib.rs b/backend/windmill-api-flow-conversations/src/lib.rs index 80eebfeda9..0ec295c23f 100644 --- a/backend/windmill-api-flow-conversations/src/lib.rs +++ b/backend/windmill-api-flow-conversations/src/lib.rs @@ -37,6 +37,19 @@ pub struct FlowConversationMessage { pub created_seq: i64, pub step_name: Option, pub success: bool, + /// On a tool row, the arguments the model wrote. For a Windmill tool these exclude the + /// inputs its step wires in. Null for a web search, whose query the provider does not + /// return. + pub tool_arguments: Option, + /// On a tool row, the text the model got back, or what the call failed with; a web + /// search's citations. + pub tool_result: Option, + /// On an answer, the thinking that produced it; on a tool row, the thinking that led to + /// the call. The agent job keeps the turn's thinking as one string. + pub reasoning: Option, + /// The files a user message carried, as object-storage references + /// (`[{input, s3, storage?, filename?}]`). + pub attachments: Option, } /// Which conversations a listing holds. A test chat was started from the editor's test @@ -247,7 +260,7 @@ async fn list_messages( let messages = if let Some(after_seq) = query.after_seq { sqlx::query_as!( FlowConversationMessage, - r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success + r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning, attachments FROM flow_conversation_message WHERE conversation_id = $1 AND created_seq > $2 @@ -264,9 +277,9 @@ async fn list_messages( // Fetch messages for this conversation, oldest first, but reverse the order of the messages for easy rendering on the frontend sqlx::query_as!( FlowConversationMessage, - r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success + r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning, attachments FROM ( - SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success + SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning, attachments FROM flow_conversation_message WHERE conversation_id = $1 ORDER BY created_seq DESC diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index a6039ca2e4..7184f8ecec 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -26,7 +26,9 @@ use windmill_api_auth::{check_scopes, get_scope_tags, ApiAuthed}; use windmill_common::{ db::{UserDB, UserDbWithAuthed}, error::{self, Error}, - flow_conversations::{add_message_to_conversation_tx, MessageType}, + flow_conversations::{ + add_message_to_conversation_tx, message_attachments, MessageExtras, MessageType, + }, get_latest_flow_version_info_for_path, jobs::{ check_tag_available_for_workspace_internal, format_result, script_path_to_payload, @@ -672,6 +674,8 @@ pub async fn handle_chat_conversation_messages( user_message_raw: Option<&Box>, job_id: Uuid, is_test: bool, + // The run's args, for the files the message carried. + args: &HashMap>, ) -> error::Result<()> { // Names the query parameter rather than the field: it is not a flow argument, and // supplying it as one is the first thing tried on reading `memory_id is required`. @@ -708,8 +712,8 @@ pub async fn handle_chat_conversation_messages( ) .await?; - // The run this message started. Its args are the only record of what the message - // carried besides its text — attachments and every other flow input — and nothing + // The run this message started. The row keeps the files the message carried as + // references; its args are the only record of every other flow input, and nothing // written later points at them: an assistant row holds the AI agent step's job. add_message_to_conversation_tx( tx, @@ -719,6 +723,7 @@ pub async fn handle_chat_conversation_messages( MessageType::User, None, true, + Some(&MessageExtras { attachments: message_attachments(args), ..Default::default() }), ) .await?; @@ -841,6 +846,7 @@ pub async fn run_flow<'c>( args.args.get("user_message"), uuid, false, + &args.args, ) .await?; } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 60b3c2ad25..8447666a94 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -28402,6 +28402,50 @@ components: success: type: boolean description: Whether the message is a success + tool_arguments: + type: string + nullable: true + description: >- + On a tool row, the arguments the model wrote for the call. For a script, flow or + AI agent tool these exclude the inputs its step wires in, which only the tool's + job holds. Null for a provider-native web search, whose query the provider does + not return. + tool_result: + type: string + nullable: true + description: >- + On a tool row, the text the model got back from the call, or what the call + failed with — the row's own text names the tool rather than the reason. For a + provider-native web search, its citations. + reasoning: + type: string + nullable: true + description: >- + On an answer, the thinking that produced it; on a tool row, the thinking that + led to the call. Each round's thinking is on one row. The agent job's result + keeps the turn's thinking as a single string. + attachments: + type: array + nullable: true + description: >- + The files a user message carried, as object-storage references: every flow + input other than user_message that held one or a list of them, at most 20. Never + file bytes or a presigned URL. + items: + type: object + required: [input, s3] + properties: + input: + type: string + description: The flow input that held the file + s3: + type: string + description: The file's key in object storage + storage: + type: string + description: The secondary storage holding the file, absent for the primary one + filename: + type: string EndpointTool: type: object diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 41a42d58d8..4149292200 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -9556,6 +9556,7 @@ async fn run_preview_flow_job( uuid, // Run from the editor's test panel: a trial, not a real conversation. true, + &flow_args, ) .await?; } diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 1abe82e5a7..91bfa4a39f 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -1,7 +1,11 @@ +use std::collections::HashMap; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; use sqlx::{self, FromRow}; use uuid::Uuid; +use windmill_types::s3::S3Object; use crate::db::DB; use crate::error::Result; @@ -139,6 +143,65 @@ async fn lock_conversation( .await?) } +/// What a row carries beyond its text. A chat is rebuilt from its rows alone, without +/// reading jobs, so every tool row carries the model's call and what the model got back: a +/// Windmill tool's job holds the args its input transforms produced rather than the model's, +/// and an MCP tool's call sits among every call of the turn in the agent's job. That job's +/// `reasoning` is one string for the whole turn, where the rows keep it per iteration. +#[derive(Debug, Clone, Default)] +pub struct MessageExtras { + pub tool_arguments: Option, + pub tool_result: Option, + pub reasoning: Option, + /// The files a user message carried; see `message_attachments`. + pub attachments: Vec, +} + +/// The most files a user message keeps references to; the rest are dropped. +pub const MAX_MESSAGE_ATTACHMENTS: usize = 20; + +/// A file a user message carried, as the object-storage reference its run received. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MessageAttachment { + /// The flow input that held it. + pub input: String, + pub s3: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub storage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, +} + +/// The files a run's args carry for its user message: every top-level input other than +/// `user_message` whose value is an object-storage reference or a list of them, in input +/// name order, capped at `MAX_MESSAGE_ATTACHMENTS`. Only the reference is kept: `presigned` +/// grants access to the file, and any other value may be the file's bytes. +pub fn message_attachments(args: &HashMap>) -> Vec { + let mut inputs: Vec<_> = args + .iter() + .filter(|(name, _)| name.as_str() != "user_message") + .collect(); + inputs.sort_by(|a, b| a.0.cmp(b.0)); + inputs + .into_iter() + .flat_map(|(name, value)| { + serde_json::from_str::(value.get()) + .map(|object| vec![object]) + .or_else(|_| serde_json::from_str::>(value.get())) + .unwrap_or_default() + .into_iter() + .filter(|object| !object.s3.is_empty()) + .map(move |object| MessageAttachment { + input: name.clone(), + s3: object.s3, + storage: object.storage, + filename: object.filename, + }) + }) + .take(MAX_MESSAGE_ATTACHMENTS) + .collect() +} + /// Add a message to a conversation using an existing transaction /// If the conversation doesn't exist, logs a warning and returns Ok (no error thrown) /// This allows memory_id to be used for agent memory without requiring a conversation @@ -150,6 +213,7 @@ pub async fn add_message_to_conversation_tx( message_type: MessageType, step_name: Option<&str>, success: bool, + extras: Option<&MessageExtras>, ) -> Result<()> { // Check if conversation exists first let conversation_exists = sqlx::query!( @@ -170,14 +234,21 @@ pub async fn add_message_to_conversation_tx( // Insert the message sqlx::query!( - "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success) - VALUES ($1, $2, $3, $4, $5, $6)", + "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning, attachments) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", conversation_id, message_type as MessageType, content, job_id, step_name, - success + success, + extras.and_then(|e| e.tool_arguments.as_deref()), + extras.and_then(|e| e.tool_result.as_deref()), + extras.and_then(|e| e.reasoning.as_deref()), + extras + .map(|e| &e.attachments) + .filter(|attachments| !attachments.is_empty()) + .map(sqlx::types::Json) as Option>> ) .execute(&mut **tx) .await?; @@ -213,6 +284,47 @@ pub async fn delete_conversation_memory( #[cfg(test)] mod tests { use super::*; + use serde_json::{json, value::to_raw_value}; + + fn args(values: serde_json::Value) -> HashMap> { + values + .as_object() + .unwrap() + .iter() + .map(|(name, value)| (name.clone(), to_raw_value(value).unwrap())) + .collect() + } + + #[test] + fn keeps_only_object_storage_references() { + let attachments = message_attachments(&args(json!({ + "user_message": { "s3": "not/an/attachment.png" }, + "avatar": { "s3": "u/a.png", "storage": "secondary", "presigned": "https://signed" }, + "files": [ + { "s3": "u/b.pdf", "filename": "b.pdf" }, + { "s3": "" } + ], + "photo": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "count": 3 + }))); + assert_eq!( + serde_json::to_value(&attachments).unwrap(), + json!([ + { "input": "avatar", "s3": "u/a.png", "storage": "secondary" }, + { "input": "files", "s3": "u/b.pdf", "filename": "b.pdf" } + ]) + ); + } + + #[test] + fn caps_the_references_of_one_message() { + let files: Vec<_> = (0..25) + .map(|i| json!({ "s3": format!("u/{i}.png") })) + .collect(); + let attachments = message_attachments(&args(json!({ "files": files }))); + assert_eq!(attachments.len(), MAX_MESSAGE_ATTACHMENTS); + assert_eq!(attachments.last().unwrap().s3, "u/19.png"); + } /// A string names a memory only within its workspace and flow; a uuid is used as is. #[test] diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 9c15229ba6..7e775f34fe 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -33,7 +33,7 @@ use windmill_common::{ client::AuthedClient, db::DB, error::Error, - flow_conversations::MessageType, + flow_conversations::{MessageExtras, MessageType}, flow_status::AgentAction, flows::FlowModuleValue, worker::{to_raw_value, Connection}, @@ -74,6 +74,9 @@ pub struct ToolExecutionContext<'a> { pub stream_event_processor: Option<&'a StreamEventProcessor>, pub flow_context: &'a mut FlowContext, pub omit_output_from_conversation: bool, + /// The thinking that led to this round's calls, stored on the first tool row written. + /// None when the round wrote text, whose row carries it. + pub reasoning: Option, pub previous_result: &'a Option>, pub id_context: &'a Option, @@ -235,9 +238,24 @@ async fn execute_mcp_tool_call( update_flow_status_module_with_actions_success(ctx.db, parent_job, true).await?; } - // Add tool message to conversation if chat_input_enabled + // An MCP tool runs inside the agent's job, whose result holds every call of the + // turn and nothing tying one of them to this row: same job id for all of them, + // no call id on the row. Kept here so the card shows this call — and the row + // names that job, so retention sweeps it with every other row of the turn. let content = format!("Used {} tool", tool_call.function.name); - add_tool_message_to_chat(ctx, None, &content, true).await; + let agent_job_id = ctx.job.id; + add_tool_message_to_chat( + ctx, + Some(agent_job_id), + &content, + true, + Some(MessageExtras { + tool_arguments: Some(tool_call.function.arguments.clone()), + tool_result: Some(result_str), + ..Default::default() + }), + ) + .await; } Err(e) => { let error_msg = format!("MCP tool error: {}", e); @@ -271,8 +289,23 @@ async fn execute_mcp_tool_call( update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?; } - // Add tool message to conversation if chat_input_enabled - add_tool_message_to_chat(ctx, None, &error_msg, false).await; + // Add tool message to conversation if chat_input_enabled. The row is worded from + // the tool, like every other tool row, and the error it failed with is its result + // — the one field a call that produced nothing else still has something to put in. + let agent_job_id = ctx.job.id; + let content = format!("Error executing {}", tool_name); + add_tool_message_to_chat( + ctx, + Some(agent_job_id), + &content, + false, + Some(MessageExtras { + tool_arguments: Some(tool_call.function.arguments.clone()), + tool_result: Some(error_msg.clone()), + ..Default::default() + }), + ) + .await; } } @@ -680,8 +713,8 @@ async fn handle_tool_execution_error( update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?; } - // Add tool message to conversation if chat_input_enabled (error case) - add_tool_message_to_chat(ctx, Some(job_id), &error_message, false).await; + let (content, extras) = windmill_tool_row(tool_call, false, &error_message); + add_tool_message_to_chat(ctx, Some(job_id), &content, false, Some(extras)).await; Ok(()) } @@ -782,13 +815,17 @@ async fn handle_tool_execution_success( ..Default::default() }); - // Stream tool result (success case) + let (content, extras) = windmill_tool_row(tool_call, success, &tool_result); + + // The job ran; whether it ran successfully is `success`, and the row stored below is + // worded from it. The stream has to carry the same value, or the card the reader watches + // and the row that replaces it describe the same call differently. if let Some(stream_event_processor) = ctx.stream_event_processor { let tool_result_event = StreamingEvent::ToolResult { call_id: tool_call.id.clone(), function_name: tool_call.function.name.clone(), result: tool_result, - success: true, + success, }; stream_event_processor .send(tool_result_event, final_events_str) @@ -799,28 +836,56 @@ async fn handle_tool_execution_success( update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?; } - // Add tool message to conversation if chat_input_enabled + add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await; + + Ok(()) +} + +/// A Windmill tool's conversation row: worded from the tool, carrying the model's call and +/// the exact text the model got back, the same text agent memory keeps for that tool +/// message, so a card needs no job fetch. The call is the model's arguments, not the job's +/// args: the step's input transforms add inputs the model never wrote. +fn windmill_tool_row( + tool_call: &OpenAIToolCall, + success: bool, + sent_to_model: &str, +) -> (String, MessageExtras) { let content = if success { format!("Used {} tool", tool_call.function.name) } else { format!("Error executing {}", tool_call.function.name) }; - - add_tool_message_to_chat(ctx, Some(job_id), &content, success).await; - - Ok(()) + let extras = MessageExtras { + tool_arguments: Some(tool_call.function.arguments.clone()), + tool_result: Some(sent_to_model.to_string()), + ..Default::default() + }; + (content, extras) } /// Add tool message to conversation if chat is enabled async fn add_tool_message_to_chat( ctx: &mut ToolExecutionContext<'_>, + // The job this row belongs to: the tool's own where it has one, else the agent's, which + // is the job it ran inside. Every row names one so that retention collects the whole + // turn — `delete_jobs` removes messages by `job_id = ANY(..)` (there is no FK on the + // column; `drop_v2_job_side_table_cascades` dropped it), and a row naming no job would + // survive every purge and leave a conversation that can never become empty. tool_job_id: Option, content: &str, success: bool, + // The model's call and what it got back; every tool row carries both. + extras: Option, ) { if ctx.omit_output_from_conversation { return; } + let extras = match ctx.reasoning.take() { + Some(reasoning) => { + Some(MessageExtras { reasoning: Some(reasoning), ..extras.unwrap_or_default() }) + } + None => extras, + }; let chat_enabled = ctx .flow_context @@ -835,41 +900,74 @@ async fn add_tool_message_to_chat( .as_ref() .and_then(|fs| fs.memory_id) { - let db_clone = ctx.db.clone(); let effective_step_id = ctx .flow_step_id_override .or(ctx.job.flow_step_id.as_deref()); let step_name = get_step_name_from_flow(ctx.summary.as_deref(), effective_step_id); - let content = content.to_string(); - // Spawn task because we do not need to wait for the result - tokio::spawn(async move { - if let Err(e) = add_message_to_conversation( - &db_clone, - &memory_id, - tool_job_id, - &content, - MessageType::Tool, - &step_name, - success, - ) - .await - { - tracing::warn!( - "Failed to add tool message to conversation {}: {}", - memory_id, - e - ); - } - }); + // Awaited, not spawned: `created_seq` is the transcript's order, so a round's rows + // must commit in the order of its calls. Calls run one after another; running them + // in parallel would need their rows written in call order all the same. + if let Err(e) = add_message_to_conversation( + ctx.db, + &memory_id, + tool_job_id, + content, + MessageType::Tool, + &step_name, + success, + extras.as_ref(), + ) + .await + { + tracing::warn!( + "Failed to add tool message to conversation {}: {}", + memory_id, + e + ); + } } } } #[cfg(test)] mod tests { - use super::extract_ai_agent_output; + use super::{extract_ai_agent_output, windmill_tool_row}; use serde_json::value::RawValue; + use windmill_ai::ai_types::{OpenAIFunction, OpenAIToolCall}; + + #[test] + fn a_windmill_tool_row_carries_the_models_call_and_what_it_got_back() { + let tool_call = OpenAIToolCall { + id: "call_1".to_string(), + function: OpenAIFunction { + name: "get_price".to_string(), + arguments: r#"{"item":"widget"}"#.to_string(), + }, + r#type: "function".to_string(), + extra_content: None, + }; + + let (content, extras) = windmill_tool_row(&tool_call, true, r#"{"price":42}"#); + assert_eq!(content, "Used get_price tool"); + assert_eq!( + extras.tool_arguments.as_deref(), + Some(r#"{"item":"widget"}"#) + ); + assert_eq!(extras.tool_result.as_deref(), Some(r#"{"price":42}"#)); + + let (content, extras) = + windmill_tool_row(&tool_call, false, "Error running tool: ExecutionErr: boom"); + assert_eq!(content, "Error executing get_price"); + assert_eq!( + extras.tool_arguments.as_deref(), + Some(r#"{"item":"widget"}"#) + ); + assert_eq!( + extras.tool_result.as_deref(), + Some("Error running tool: ExecutionErr: boom") + ); + } #[test] fn extracts_only_the_output_of_an_agent_result() { diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index e717f2172e..3efdc67a3d 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -13,7 +13,7 @@ use windmill_common::flows::FlowModuleValue; use windmill_common::{ db::DB, error::Error, - flow_conversations::{add_message_to_conversation_tx, MessageType}, + flow_conversations::{add_message_to_conversation_tx, MessageExtras, MessageType}, flow_status::AgentAction, flows::{InputTransform, Step}, jobs::JobKind, @@ -213,6 +213,7 @@ pub async fn add_message_to_conversation( message_type: MessageType, step_name: &Option, success: bool, + extras: Option<&MessageExtras>, ) -> Result<(), Error> { let mut tx = db.begin().await?; add_message_to_conversation_tx( @@ -223,6 +224,7 @@ pub async fn add_message_to_conversation( message_type, step_name.as_deref(), success, + extras, ) .await?; tx.commit().await?; diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index af3aa01b2e..efc7ffa1b3 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -44,7 +44,7 @@ use windmill_common::{ client::AuthedClient, db::DB, error::{self, Error}, - flow_conversations::{memory_key, MessageType}, + flow_conversations::{memory_key, MessageExtras, MessageType}, flow_status::AgentAction, flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue}, get_latest_hash_for_path, @@ -1690,29 +1690,34 @@ pub async fn run_agent( }); if persist_output_to_conversation { if let Some(conversation_id) = conversation_id { - let agent_job_id = job.id; - let db_clone = db.clone(); - let message_content = "Used websearch tool successfully".to_string(); - let step_name = step_name.clone(); - tokio::spawn(async move { - if let Err(e) = add_message_to_conversation( - &db_clone, - &conversation_id, - Some(agent_job_id), - &message_content, - MessageType::Tool, - &step_name, - true, - ) - .await - { - tracing::warn!( - "Failed to add websearch tool message to conversation {}: {}", - conversation_id, - e - ); - } + // The search ran inside the provider's call, so this job's args + // describe the agent, not the search: its sources reach the row + // only if they are written here. + let extras = (!annotations.is_empty()).then(|| MessageExtras { + tool_result: serde_json::to_string(&annotations).ok(), + ..Default::default() }); + // Awaited like every row of the loop, so rows commit in turn order. + // Worded like every other tool row, so a reader recovers the tool + // name from the sentence. + if let Err(e) = add_message_to_conversation( + db, + &conversation_id, + Some(job.id), + "Used websearch tool", + MessageType::Tool, + &step_name, + true, + extras.as_ref(), + ) + .await + { + tracing::warn!( + "Failed to add websearch tool message to conversation {}: {}", + conversation_id, + e + ); + } } } } @@ -1742,31 +1747,29 @@ pub async fn run_agent( // Add assistant message to conversation if chat_input_enabled if persist_output_to_conversation && !response_content.is_empty() { if let Some(conversation_id) = conversation_id { - let agent_job_id = job.id; - let db_clone = db.clone(); - let message_content = response_content.clone(); - let step_name = step_name.clone(); - - // Spawn task because we do not need to wait for the result - tokio::spawn(async move { - if let Err(e) = add_message_to_conversation( - &db_clone, - &conversation_id, - Some(agent_job_id), - &message_content, - MessageType::Assistant, - &step_name, - true, - ) - .await - { - tracing::warn!( - "Failed to add assistant message to conversation {}: {}", - conversation_id, - e - ); - } + // This iteration's thinking goes on the answer's row; the job + // result only keeps the turn's thinking as one string. + let extras = response_reasoning.clone().map(|reasoning| { + MessageExtras { reasoning: Some(reasoning), ..Default::default() } }); + if let Err(e) = add_message_to_conversation( + db, + &conversation_id, + Some(job.id), + response_content, + MessageType::Assistant, + &step_name, + true, + extras.as_ref(), + ) + .await + { + tracing::warn!( + "Failed to add assistant message to conversation {}: {}", + conversation_id, + e + ); + } } } } @@ -1805,6 +1808,18 @@ pub async fn run_agent( ..Default::default() }); + // A round's thinking is stored on one row, the first the round writes, which is + // where the stream shows it: its text row when it wrote text, else the row of + // its first call — the answer row below when that call is the structured-output + // tool. Two rows carrying it would show it twice after a reload. + let call_reasoning = response_reasoning + .clone() + .filter(|_| response_content.as_deref().unwrap_or("").is_empty()); + let structured_output_first = structured_output_tool_name + .as_ref() + .zip(tool_calls.first()) + .map_or(false, |(name, tc)| tc.function.name == *name); + // Handle tool calls using extracted tools module let tool_execution_ctx = ToolExecutionContext { db, @@ -1823,6 +1838,11 @@ pub async fn run_agent( stream_event_processor: stream_event_processor.as_ref(), flow_context: &mut flow_context, omit_output_from_conversation, + reasoning: if structured_output_first { + None + } else { + call_reasoning.clone() + }, previous_result: &previous_result, id_context: &id_context, tool_abort_handles: tool_abort_handles.clone(), @@ -1841,6 +1861,41 @@ pub async fn run_agent( .await?; messages.extend(tool_messages); + + // A structured answer is the arguments of the structured-output tool call, + // on which the loop ends without a text iteration, so its row is written here. + if tool_used_structured_output && persist_output_to_conversation { + if let (Some(conversation_id), Some(OpenAIContent::Text(answer))) = + (conversation_id, tool_content.as_ref()) + { + let extras = call_reasoning + .clone() + .filter(|_| structured_output_first) + .map(|reasoning| MessageExtras { + reasoning: Some(reasoning), + ..Default::default() + }); + if let Err(e) = add_message_to_conversation( + db, + &conversation_id, + Some(job.id), + answer, + MessageType::Assistant, + &step_name, + true, + extras.as_ref(), + ) + .await + { + tracing::warn!( + "Failed to add structured answer to conversation {}: {}", + conversation_id, + e + ); + } + } + } + if let Some(tc) = tool_content { content = Some(tc); } @@ -1861,9 +1916,6 @@ pub async fn run_agent( // Add assistant message to conversation if chat_input_enabled if persist_output_to_conversation { if let Some(conversation_id) = conversation_id { - let agent_job_id = job.id; - let db_clone = db.clone(); - // Create extended version with type discriminator for conversation storage // This avoids conflicts with outputs that are of the same format as S3 objects let s3_with_type = S3ObjectWithType { @@ -1874,26 +1926,24 @@ pub async fn run_agent( let message_content = serde_json::to_string(&s3_with_type) .unwrap_or_else(|_| content.get().to_string()); - // Spawn task because we do not need to wait for the result - tokio::spawn(async move { - if let Err(e) = add_message_to_conversation( - &db_clone, - &conversation_id, - Some(agent_job_id), - &message_content, - MessageType::Assistant, - &step_name, - true, - ) - .await - { - tracing::warn!( - "Failed to add assistant message to conversation {}: {}", - conversation_id, - e - ); - } - }); + if let Err(e) = add_message_to_conversation( + db, + &conversation_id, + Some(job.id), + &message_content, + MessageType::Assistant, + &step_name, + true, + None, + ) + .await + { + tracing::warn!( + "Failed to add assistant message to conversation {}: {}", + conversation_id, + e + ); + } } } diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index ed543bd517..6c0dcaf279 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -2236,6 +2236,7 @@ async fn add_tool_message_to_conversation( MessageType::Assistant, None, success, + None, ) .await?; tx.commit().await?; diff --git a/chat-sdk/README.md b/chat-sdk/README.md index ba76d1d507..0f1082de45 100644 --- a/chat-sdk/README.md +++ b/chat-sdk/README.md @@ -56,8 +56,10 @@ not a replacement of the previous answer. The transport also carries the history helpers: `transport.loadMessages(id)` returns `UIMessage`s for `useChat({ messages })` or `setMessages`, `transport.listConversations()` -and `transport.deleteConversation(id)`. Attachments are not supported: `sendMessage` with -`files` is refused with an explanatory error. +and `transport.deleteConversation(id)`. A loaded user message lists the files it carried +in `metadata.attachments`; `WindmillChatApi.attachmentUrl` gives each one's download URL. +Sending attachments is not supported: `sendMessage` with `files` is refused with an +explanatory error. ## assistant-ui diff --git a/chat-sdk/src/ai-sdk.ts b/chat-sdk/src/ai-sdk.ts index ba500446a3..d93bb99e2f 100644 --- a/chat-sdk/src/ai-sdk.ts +++ b/chat-sdk/src/ai-sdk.ts @@ -1,5 +1,10 @@ import type { ChatTransport, UIMessage, UIMessageChunk, UIMessagePart } from 'ai' -import { WindmillApiError, WindmillChatApi, type WindmillChatApiOptions } from './api' +import { + WindmillApiError, + WindmillChatApi, + type FlowConversationMessage, + type WindmillChatApiOptions +} from './api' import { followJob } from './follow' import type { AgentStreamEvent } from './stream' import type { ChatMessage, Conversation } from './types' @@ -101,7 +106,9 @@ export function createWindmillChatTransport { await this.#request(`jobs_u/queue/cancel/${encodeURIComponent(jobId)}`, { method: 'POST', diff --git a/chat-sdk/src/assistant-ui.ts b/chat-sdk/src/assistant-ui.ts index 36133c2272..b9a76db679 100644 --- a/chat-sdk/src/assistant-ui.ts +++ b/chat-sdk/src/assistant-ui.ts @@ -92,6 +92,7 @@ export function toThreadMessage(turn: WindmillTurn): ThreadMessageLike { } const content: ThreadContentPart[] = [] for (const m of turn.messages) { + if (m.reasoning) content.push({ type: 'reasoning', text: m.reasoning }) if (m.role === 'tool') { const args = parseJsonOr(m.tool?.arguments) content.push({ @@ -100,12 +101,12 @@ export function toThreadMessage(turn: WindmillTurn): ThreadMessageLike { toolName: m.tool?.name ?? 'tool', args: (isJsonObject(args) ? args : args === undefined ? {} : { input: args }) as ToolCallArgs, argsText: m.tool?.arguments ?? '', - result: m.tool?.status === 'running' ? undefined : (parseJsonOr(m.tool?.result) ?? m.content), + result: + m.tool?.status === 'running' ? undefined : m.tool?.result !== undefined ? parseJsonOr(m.tool.result) : m.content, isError: m.tool?.status === 'error' }) continue } - if (m.reasoning) content.push({ type: 'reasoning', text: m.reasoning }) if (m.content) content.push({ type: 'text', text: m.content }) } const last = turn.messages[turn.messages.length - 1] diff --git a/chat-sdk/src/chat.ts b/chat-sdk/src/chat.ts index 8055ea2234..b13fa89173 100644 --- a/chat-sdk/src/chat.ts +++ b/chat-sdk/src/chat.ts @@ -375,16 +375,21 @@ class ChatImpl implements Chat { tool: { ...existing.tool!, ...toolPatch } } } else { + // Thinking that produced no text led to this call, and is stored on its row. + const a = turn.assistantId ? messages.findIndex((m) => m.id === turn.assistantId) : -1 + const reasoning = a >= 0 && messages[a].content === '' ? messages.splice(a, 1)[0].reasoning : undefined messages.push({ id: `pending-${randomId()}`, role: 'tool', content: content ?? '', + reasoning, success: success ?? true, createdAt: now(), pending: true, tool: { callId, name, status: 'running', ...toolPatch } }) } + turn.assistantId = undefined } const appendAssistant = (text: string, reasoning: string) => { const i = turn.assistantId @@ -419,17 +424,14 @@ class ChatImpl implements Chat { case 'reasoning_token_delta': appendAssistant('', event.content) break + // A call completes the round's text: text after it is a new message. case 'tool_call': - // The round's text is complete; text after the tool result is a new message. - turn.assistantId = undefined upsertTool(event.call_id, event.function_name, { status: 'running' }) break case 'tool_call_arguments': - turn.assistantId = undefined upsertTool(event.call_id, event.function_name, { arguments: event.arguments }) break case 'tool_execution': - turn.assistantId = undefined upsertTool(event.call_id, event.function_name, { status: 'running' }) break case 'tool_result': @@ -643,22 +645,54 @@ class ChatImpl implements Chat { for (const row of rows.map(fromRow)) { if (known.has(row.id)) continue known.add(row.id) - const i = messages.findIndex( + let i = messages.findIndex( (m) => m.seq === undefined && m.role === row.role && (m.content === row.content || (row.tool !== undefined && m.tool?.name === row.tool.name)) ) + // A structured answer streams as the call of the structured-output tool, whose + // arguments are the answer's text: its row replaces that call. Only past the newest + // user message, where a stopped turn's identical call cannot be. + if (i < 0 && row.role === 'assistant') { + let j = messages.length - 1 + while (j >= 0 && messages[j].role !== 'user') { + const m = messages[j] + if (m.seq === undefined && m.role === 'tool' && m.tool?.arguments === row.content) i = j + j-- + } + } if (i >= 0) { const m = messages[i] messages[i] = { ...row, id: m.id, reasoning: m.reasoning ?? row.reasoning, - tool: m.tool ? { ...m.tool, status: row.tool?.status ?? m.tool.status } : row.tool + // The stream's call wins where it has a value; a stream cut short leaves gaps the row fills. + tool: + row.role === 'tool' && m.tool + ? { + ...m.tool, + arguments: m.tool.arguments ?? row.tool?.arguments, + result: m.tool.result ?? row.tool?.result, + status: row.tool?.status ?? m.tool.status + } + : row.tool } } else { - messages.push(row) + // A tool row nothing streamed, such as a provider-native web search, goes where a + // reload puts it: after the last message of a lower `seq`, before the streamed answer. + // Any other row closes the turn, a failure included, and stays last: above a streamed + // message that never got a row, it would hide the turn's failure. + let at = messages.length + for (let j = messages.length - 1; row.role === 'tool' && j >= 0; j--) { + const seq = messages[j].seq + if (seq !== undefined && seq < row.seq!) { + at = j + 1 + break + } + } + messages.splice(at, 0, row) } } this.#set({ messages }) @@ -756,7 +790,19 @@ function fromRow(row: FlowConversationMessage): ChatMessage { stepName: row.step_name ?? undefined, pending: false, seq: row.created_seq, - tool: toolName ? { name: toolName, status: success ? 'success' : 'error' } : undefined + reasoning: row.reasoning ?? undefined, + attachments: row.attachments ?? undefined, + // The call the row carries: the model's arguments and what the model got back. For a + // failed tool the result is what it failed with, and the row's text names the tool + // rather than the reason. + tool: toolName + ? { + name: toolName, + status: success ? 'success' : 'error', + arguments: row.tool_arguments ?? undefined, + result: row.tool_result ?? undefined + } + : undefined } } diff --git a/chat-sdk/src/index.ts b/chat-sdk/src/index.ts index 1c18a5924b..aedaa8b19f 100644 --- a/chat-sdk/src/index.ts +++ b/chat-sdk/src/index.ts @@ -16,6 +16,7 @@ export { followJob, type FollowEvent } from './follow' export { extractChatAnswer, conversationIdFor } from './utils' export type { Chat, + ChatAttachment, ChatMessage, ChatOptions, ChatRole, diff --git a/chat-sdk/src/types.ts b/chat-sdk/src/types.ts index 5e0d673bd9..87fd5a7b0a 100644 --- a/chat-sdk/src/types.ts +++ b/chat-sdk/src/types.ts @@ -26,19 +26,23 @@ export interface ToolInvocation { status: 'running' | 'success' | 'error' } +export interface ChatAttachment { input: string; s3: string; storage?: string; filename?: string } + export interface ChatMessage { id: string role: ChatRole content: string /** The model's reasoning summary, when the provider streams one. */ reasoning?: string - /** Set on `tool` messages that came from the live stream. */ + /** The call on a `tool` message, from the live stream or its stored row; `callId` is only known from the stream. */ tool?: ToolInvocation success: boolean createdAt: string jobId?: string /** The flow step that produced the message. */ stepName?: string + /** The files a user message carried, as object-storage references. */ + attachments?: ChatAttachment[] /** True while the message is optimistic or still streaming. */ pending: boolean /** Id of the persisted row once the server has it; `id` itself never changes, so list keys stay stable. */ diff --git a/chat-sdk/test/ai-sdk.test.ts b/chat-sdk/test/ai-sdk.test.ts index 7f30f26edc..70b7c40f33 100644 --- a/chat-sdk/test/ai-sdk.test.ts +++ b/chat-sdk/test/ai-sdk.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import type { UIMessage, UIMessageChunk } from 'ai' import { createWindmillChatTransport, toUIMessages } from '../src/ai-sdk' import type { ChatMessage } from '../src/types' -import { fetchMock, json, ndjson, sse, text, type Route } from './support' +import { fetchMock, json, messageRow, ndjson, sse, text, type Route } from './support' const FLOW = 'f/chat/agent' const run: Route = (c) => @@ -141,6 +141,26 @@ describe('createWindmillChatTransport', () => { expect(second[1]).toMatchObject({ errorText: 'ExecutionErr: boom' }) }) + test('loads the attachments of a user row, the call an MCP tool row carries and the reasoning behind an answer', async () => { + const { fetch } = fetchMock((c) => + c.method === 'GET' && c.url.pathname.endsWith('/messages') + ? json([ + messageRow(1, 'user', 'hi', { attachments: [{ input: 'files', s3: 'chat/a.png', filename: 'a.png' }] }), + messageRow(2, 'tool', 'Used lookup tool', { job_id: 'agent-job', reasoning: 'why', tool_arguments: '{"q":1}', tool_result: '42' }), + messageRow(3, 'assistant', 'The answer is 42', { reasoning: 'hmm' }) + ]) + : undefined + ) + const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, fetch }) + const ui = await transport.loadMessages('c') + expect(ui.map((m) => m.parts.map((p) => p.type))).toEqual([['text'], ['reasoning', 'dynamic-tool', 'reasoning', 'text']]) + expect(ui[0].metadata).toEqual({ attachments: [{ input: 'files', s3: 'chat/a.png', filename: 'a.png' }] }) + expect(ui[1].metadata).toBeUndefined() + expect(ui[1].parts[0]).toMatchObject({ type: 'reasoning', text: 'why' }) + expect(ui[1].parts[1]).toMatchObject({ toolName: 'lookup', state: 'output-available', input: { q: 1 }, output: 42 }) + expect(ui[1].parts[2]).toMatchObject({ type: 'reasoning', text: 'hmm' }) + }) + test('refuses attachments with a clear error', async () => { const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, fetch: fetchMock().fetch }) await expect( @@ -175,4 +195,11 @@ describe('toUIMessages', () => { expect(ui[1].parts[0]).toMatchObject({ toolCallId: 'c1', toolName: 'lookup', state: 'output-available', input: { q: 1 }, output: 42 }) expect(ui[3].parts[0]).toMatchObject({ state: 'output-error', errorText: 'Error executing lookup' }) }) + + test('keeps a stored JSON null result rather than the row text', () => { + const ui = toUIMessages([ + { success: true, createdAt: '2026-01-01T00:00:00Z', pending: false, id: 't1', role: 'tool', content: 'Used notify tool', tool: { callId: 'c1', name: 'notify', status: 'success', arguments: '{}', result: 'null' } } + ]) + expect(ui[0].parts[0]).toMatchObject({ type: 'dynamic-tool', state: 'output-available', output: null }) + }) }) diff --git a/chat-sdk/test/api.test.ts b/chat-sdk/test/api.test.ts new file mode 100644 index 0000000000..0facf03e97 --- /dev/null +++ b/chat-sdk/test/api.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test' +import { WindmillChatApi } from '../src/api' + +describe('WindmillChatApi.attachmentUrl', () => { + test('points at the workspace download endpoint, with the storage only when there is one', () => { + const api = new WindmillChatApi({ baseUrl: 'https://wm.test/api/', workspace: 'my ws' }) + expect(api.attachmentUrl({ input: 'files', s3: 'chat/a b&c.png', storage: 'secondary' })).toBe( + 'https://wm.test/api/w/my%20ws/job_helpers/download_s3_file?file_key=chat%2Fa+b%26c.png&storage=secondary' + ) + expect(api.attachmentUrl({ input: 'files', s3: 'chat/a.png' })).toBe( + 'https://wm.test/api/w/my%20ws/job_helpers/download_s3_file?file_key=chat%2Fa.png' + ) + }) +}) diff --git a/chat-sdk/test/assistant-ui.test.ts b/chat-sdk/test/assistant-ui.test.ts index 5641dc2907..8601ad221a 100644 --- a/chat-sdk/test/assistant-ui.test.ts +++ b/chat-sdk/test/assistant-ui.test.ts @@ -31,6 +31,13 @@ describe('assistant-ui conversion', () => { expect(toThreadMessage(turns[0])).toMatchObject({ role: 'user', content: [{ type: 'text', text: 'hi' }] }) }) + test('keeps a stored JSON null result rather than the row text', () => { + const turn = groupTurns([ + { ...base, id: 't1', role: 'tool', content: 'Used notify tool', tool: { callId: 'c1', name: 'notify', status: 'success', arguments: '{}', result: 'null' } } + ])[0] + expect(toThreadMessage(turn).content).toMatchObject([{ type: 'tool-call', toolName: 'notify', result: null }]) + }) + test('marks a failed answer as incomplete', () => { const [turn] = groupTurns([{ ...base, id: 'a', role: 'assistant', content: 'boom', success: false }]) expect(toThreadMessage(turn).status).toEqual({ type: 'incomplete', reason: 'error', error: 'boom' }) diff --git a/chat-sdk/test/chat.test.ts b/chat-sdk/test/chat.test.ts index 89c4565942..a739abf3ad 100644 --- a/chat-sdk/test/chat.test.ts +++ b/chat-sdk/test/chat.test.ts @@ -327,6 +327,113 @@ describe('createChat with server history', () => { expect(messagesCall.headers.authorization).toBeUndefined() }) + test('a persisted row brings back its attachments, reasoning and the call an MCP tool row carries', async () => { + const { fetch } = fetchMock( + (c) => + c.method === 'GET' && c.url.pathname === '/api/w/ws/flow_conversations/conv-1/messages' + ? json([ + messageRow(1, 'user', 'hi', { + attachments: [{ input: 'files', s3: 'chat/a.png', storage: 'secondary', filename: 'a.png' }] + }), + messageRow(2, 'tool', 'Used lookup tool', { + job_id: 'agent-job', + tool_arguments: '{"q":1}', + tool_result: '42' + }), + messageRow(3, 'tool', 'Error executing lookup', { + job_id: 'agent-job', + success: false, + tool_arguments: '{"q":2}', + tool_result: 'MCP tool error: boom' + }), + messageRow(4, 'assistant', 'The answer is 42', { reasoning: 'hmm' }), + messageRow(5, 'assistant', 'Hello'), + messageRow(6, 'tool', 'Used get_price tool', { + job_id: 'script-tool-job', + tool_arguments: '{"item":"widget"}', + tool_result: '{"price":42}' + }) + ]) + : undefined + ) + const chat = createChat(options({ history: 'server' }, fetch)) + await chat.selectConversation('conv-1') + + const [user, used, failed, answer, plain, scriptTool] = chat.getState().messages + expect(scriptTool).toMatchObject({ jobId: 'script-tool-job', tool: { name: 'get_price', status: 'success', arguments: '{"item":"widget"}', result: '{"price":42}' } }) + expect(user.attachments).toEqual([{ input: 'files', s3: 'chat/a.png', storage: 'secondary', filename: 'a.png' }]) + expect(answer.attachments).toBeUndefined() + expect(used.tool).toEqual({ name: 'lookup', status: 'success', arguments: '{"q":1}', result: '42' }) + expect(failed.tool).toEqual({ name: 'lookup', status: 'error', arguments: '{"q":2}', result: 'MCP tool error: boom' }) + expect(answer.reasoning).toBe('hmm') + expect(plain.reasoning).toBeUndefined() + }) + + test('a row nothing streamed, like a web search, lands before the answer as on reload', async () => { + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([ + { + type: 'update', + new_result_stream: ndjson({ type: 'token_delta', content: 'Rust.' }), + stream_offset: 1, + completed: true, + only_result: { output: 'Rust.', messages: [] } + } + ]) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([ + messageRow(91, 'user', 'hi'), + messageRow(92, 'tool', 'Used websearch tool', { job_id: 'step-1', tool_result: '[{"url":"https://example.com"}]' }), + messageRow(93, 'assistant', 'Rust.', { job_id: 'step-1' }) + ]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('hi') + expect(chat.getState().messages.map((m) => [m.role, m.content, m.seq])).toEqual([ + ['user', 'hi', 91], + ['tool', 'Used websearch tool', 92], + ['assistant', 'Rust.', 93] + ]) + }) + + test('a failure row stays after streamed text that never got a row', async () => { + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([ + { + type: 'update', + new_result_stream: ndjson({ type: 'token_delta', content: 'Let me look' }), + stream_offset: 1, + completed: true, + only_result: { error: { name: 'ExecutionErr', message: 'boom' } } + } + ]) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([messageRow(91, 'user', 'hi'), messageRow(92, 'assistant', 'boom', { job_id: 'step-1', success: false })]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('hi') + const messages = chat.getState().messages + expect(messages.map((m) => [m.content, m.success])).toEqual([ + ['hi', true], + ['Let me look', true], + ['boom', false] + ]) + }) + test('keeps the streamed answer until its row lands, even when a tool row lands first', async () => { let messageFetches = 0 const { fetch } = fetchMock( @@ -689,6 +796,120 @@ describe('createChat with server history', () => { expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'Let me check', 'Used search tool', 'Final answer']) }) + test('thinking that led to a tool call rides on the call, live and once its row lands', async () => { + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([ + { + type: 'update', + // No `tool_call_arguments`: a stream cut short leaves the call without them. + new_result_stream: ndjson( + { type: 'reasoning_token_delta', content: 'r1' }, + { type: 'tool_call', call_id: 'c1', function_name: 'lookup' }, + { type: 'tool_result', call_id: 'c1', function_name: 'lookup', result: '1', success: true }, + { type: 'token_delta', content: 'Final' } + ), + stream_offset: 4, + completed: true, + only_result: { output: 'Final', messages: [] } + } + ]) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([ + messageRow(71, 'user', 'hi'), + messageRow(72, 'tool', 'Used lookup tool', { job_id: 'step-1', reasoning: 'r1', tool_arguments: '{"q":1}', tool_result: '1' }), + messageRow(73, 'assistant', 'Final', { job_id: 'step-1' }) + ]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('hi') + const messages = chat.getState().messages + expect(messages.map((m) => [m.role, m.content, m.reasoning, m.seq])).toEqual([ + ['user', 'hi', undefined, 71], + ['tool', 'Used lookup tool', 'r1', 72], + ['assistant', 'Final', undefined, 73] + ]) + expect(messages[1].tool).toMatchObject({ callId: 'c1', arguments: '{"q":1}', result: '1', status: 'success' }) + }) + + test('a structured answer row replaces the call it streamed as', async () => { + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([ + { + type: 'update', + new_result_stream: ndjson( + { type: 'reasoning_token_delta', content: 'hmm' }, + { type: 'tool_call', call_id: 'c9', function_name: 'structured_output' }, + { type: 'tool_call_arguments', call_id: 'c9', function_name: 'structured_output', arguments: '{"n": 1}' }, + { type: 'tool_execution', call_id: 'c9', function_name: 'structured_output' } + ), + stream_offset: 4, + completed: true, + only_result: { output: { n: 1 }, messages: [] } + } + ]) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([messageRow(75, 'user', 'hi'), messageRow(76, 'assistant', '{"n": 1}', { job_id: 'step-1', reasoning: 'hmm' })]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('hi') + expect(chat.getState().messages.map((m) => [m.role, m.content, m.reasoning, m.tool])).toEqual([ + ['user', 'hi', undefined, undefined], + ['assistant', '{"n": 1}', 'hmm', undefined] + ]) + }) + + test('a structured answer row leaves a stopped turn its identical call', async () => { + const call = (id: string) => + ndjson( + { type: 'tool_call', call_id: id, function_name: 'structured_output' }, + { type: 'tool_call_arguments', call_id: id, function_name: 'structured_output', arguments: '{"ok": true}' } + ) + let jobs = 0 + const { fetch } = fetchMock( + (c) => (c.method === 'POST' && c.url.pathname.includes('/jobs/run/f/') ? text(`job-${++jobs}`) : undefined), + (c) => + c.url.pathname.endsWith('/getupdate_sse/job-1') ? sse([{ type: 'update', new_result_stream: call('c1'), stream_offset: 2 }]) : undefined, + (c) => + c.url.pathname.endsWith('/getupdate_sse/job-2') + ? sse([{ type: 'update', new_result_stream: call('c2'), stream_offset: 2, completed: true, only_result: { output: { ok: true }, messages: [] } }]) + : undefined, + (c) => (c.url.pathname.includes('/queue/cancel/') ? text('ok') : undefined), + (c) => + c.url.pathname.endsWith('/messages') + ? json([messageRow(41, 'user', 'first'), messageRow(42, 'user', 'again'), messageRow(43, 'assistant', '{"ok": true}', { job_id: 'step-2' })]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + const first = chat.sendMessage('first') + await new Promise((r) => setTimeout(r, 50)) + const stopped = chat.stop() + await first + const second = chat.sendMessage('again') + await stopped + await second + expect(chat.getState().messages.map((m) => [m.role, m.content, m.tool?.callId])).toEqual([ + ['user', 'first', undefined], + ['tool', '', 'c1'], + ['user', 'again', undefined], + ['assistant', '{"ok": true}', undefined] + ]) + }) + test('the stream asks for a server poll interval only when one is set', async () => { const answer: Route = (c) => c.url.pathname === streamPath ? sse([{ type: 'update', completed: true, only_result: 'ok' }]) : undefined diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index 78118546ac..9c9c865c1b 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -59,28 +59,77 @@ function lastTurnFailed(messages: readonly ChatMessage[]): boolean { return false } -export function toDisplayMessages(messages: readonly ChatMessage[]): DisplayMessage[] { +/** + * What a failed tool call returned, as the card's error: the card shows the error in place of + * the result. A job failure is stored as `{ message, name, stack }`; an MCP failure as plain text. + */ +function toolErrorText(result: unknown): string | undefined { + if (typeof result === 'string') return result || undefined + if (result && typeof result === 'object') { + const message = (result as { message?: unknown }).message + return typeof message === 'string' ? message : JSON.stringify(result, null, 2) + } + return undefined +} + +/** The name the worker gives the structured-output tool: suffixed when an agent tool already has it. */ +const STRUCTURED_OUTPUT_CALL = /^structured_output(_\d+)?$/ + +/** + * `busy`: the latest turn is still running, so a failed tool call is not yet its outcome. + * `stopped`: ids of the user messages of the turns the reader stopped, as message id or row id. + * A stopped turn never offers Retry, though Stop leaves its failed tool row or the cancelled + * flow's failure as its last message. + */ +export function toDisplayMessages( + messages: readonly ChatMessage[], + busy = false, + stopped: ReadonlySet = new Set() +): DisplayMessage[] { let userIndex = 0 - return messages.map((message, i): DisplayMessage => { + let latestUser = -1 + for (let i = messages.length - 1; i >= 0 && latestUser < 0; i--) { + if (messages[i].role === 'user') latestUser = i + } + return messages.flatMap((message, i): DisplayMessage[] => { switch (message.role) { - case 'user': - return { - role: 'user', - index: userIndex++, - content: message.content, - // Drives the shared Retry button. - error: turnFailed(messages, i) || undefined - } + case 'user': { + const index = userIndex++ + const settled = + !(busy && i === latestUser) && + !stopped.has(message.id) && + !(message.serverId && stopped.has(message.serverId)) + return [ + { + role: 'user', + index, + content: message.content, + // Drives the shared Retry button. + error: (settled && turnFailed(messages, i)) || undefined + } + ] + } case 'tool': { const parameters = parseToolPayload(message.tool?.arguments) const result = parseToolPayload(message.tool?.result) const failed = message.success === false - return { + // A call the turn finished without, stopped or lost before its row was written. The + // call a structured answer streams as never gets a result of its own, the answer + // being the turn's text, so it is not one. + const unfinished = + message.tool && + !message.pending && + !message.content && + !STRUCTURED_OUTPUT_CALL.test(message.tool.name) + ? `${message.tool.name} did not finish` + : undefined + const call: DisplayMessage = { role: 'tool', tool_call_id: message.id, // The card's header is the row's text, which the server only words once the // tool has returned; until then the row says what is running. - content: message.content || (message.tool ? `Running ${message.tool.name}` : ''), + content: + message.content || unfinished || (message.tool ? `Running ${message.tool.name}` : ''), // Withheld for the copilot's two plan-mode names: `toolName` is what makes // ToolExecutionDisplay render a plan card, and an agent tool that happened to // share one would silently become one. @@ -88,22 +137,39 @@ export function toDisplayMessages(messages: readonly ChatMessage[]): DisplayMess parameters, result, showDetails: parameters !== undefined || result !== undefined, - error: failed ? message.content : undefined, + error: failed ? (toolErrorText(result) ?? message.content) : unfinished, isLoading: message.pending && message.tool?.status === 'running' } + // The tool card has no thinking section: the thinking that led to the call reads + // as a card of its own, just before it. + return message.reasoning + ? [ + { + role: 'assistant', + content: '', + reasoning: message.reasoning, + stepName: message.stepName, + jobId: message.jobId, + createdAt: message.createdAt + }, + call + ] + : [call] } default: - return { - role: 'assistant', - content: message.content, - // Only the message a turn is still writing: a finalized reasoning-only - // message must not look in progress. - streaming: message.pending || undefined, - reasoning: message.reasoning, - stepName: message.stepName, - jobId: message.jobId, - createdAt: message.createdAt - } + return [ + { + role: 'assistant', + content: message.content, + // Only the message a turn is still writing: a finalized reasoning-only + // message must not look in progress. + streaming: message.pending || undefined, + reasoning: message.reasoning, + stepName: message.stepName, + jobId: message.jobId, + createdAt: message.createdAt + } + ] } }) } @@ -147,6 +213,12 @@ export class FlowChatViewHost implements ChatViewHost { #onState(state: ChatState) { const previous = this.#state this.#state = state + const landed = state.messages.filter( + (m) => m.serverId && this.#stoppedTurns.has(m.id) && !this.#stoppedTurns.has(m.serverId) + ) + if (landed.length > 0) { + this.#stoppedTurns = new Set([...this.#stoppedTurns, ...landed.map((m) => m.serverId!)]) + } if (previous.conversationId !== state.conversationId) { // A conversation opens at its end, whatever the reader was doing in the last one. this.#automaticScroll = true @@ -169,7 +241,14 @@ export class FlowChatViewHost implements ChatViewHost { } // Transcript - displayMessages = $derived.by(() => toDisplayMessages(this.#state.messages)) + displayMessages = $derived.by(() => + toDisplayMessages(this.#state.messages, isBusy(this.#state.status), this.#stoppedTurns) + ) + /** The user message of each turn stopped in this view, by message id and, once the chat has + * read its row, row id: a reopened conversation or an older page rebuilds messages from rows, + * so a turn left before its row was read is not recognised there. Only this session knows; + * a reload shows the stopped turn as its rows left it. */ + #stoppedTurns = $state.raw>(new Set()) get messages(): readonly unknown[] { return this.#state.messages } @@ -232,6 +311,13 @@ export class FlowChatViewHost implements ChatViewHost { // Stop means stop: what was typed during the run goes back to the composer rather // than waiting there to go out after some later turn settles. this.dequeueMessage() + const { messages, status } = this.#state + const turn = isBusy(status) ? [...messages].reverse().find((m) => m.role === 'user') : undefined + if (turn) { + this.#stoppedTurns = new Set( + [...this.#stoppedTurns, turn.id, turn.serverId].filter((id) => id !== undefined) + ) + } void this.#chat.stop() } // Typed off the interface: a Svelte component's own type resolves differently @@ -274,9 +360,10 @@ export class FlowChatViewHost implements ChatViewHost { // Per-message actions storedImages = () => undefined - /** Send the user message at this transcript position again. */ + /** Send the user message at this transcript position again. The position is in + * `displayMessages`, which holds more entries than the chat's messages. */ retryRequest = (messageIndex: number) => { - const message = this.#state.messages[messageIndex] + const message = this.displayMessages[messageIndex] if (!message || message.role !== 'user' || this.loading) return void this.sendRequest({ instructions: message.content }) } diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts index a7e953ecf5..eab3cc0df6 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts @@ -100,6 +100,59 @@ describe('toDisplayMessages', () => { }) }) + it('shows a call the turn finished without as an error, not as still running', () => { + const display = toDisplayMessages([ + message({ role: 'user', content: 'hi' }), + message({ role: 'tool', tool: { name: 'search', status: 'running' } }) + ]) + expect(display[1]).toMatchObject({ + content: 'search did not finish', + error: 'search did not finish', + isLoading: false + }) + + const structured = toDisplayMessages([ + message({ role: 'user', content: 'hi' }), + message({ + role: 'tool', + tool: { name: 'structured_output', status: 'running', arguments: '{"n":1}' } + }) + ]) + expect(structured[1]).toMatchObject({ content: 'Running structured_output', error: undefined }) + }) + + it('shows the thinking that led to a call as its own card, and retries by transcript position', async () => { + const rows = [ + message({ role: 'user', content: 'first' }), + message({ + role: 'tool', + content: 'Used search tool', + reasoning: 'why', + tool: { name: 'search', status: 'success' } + }), + message({ role: 'assistant', content: 'done' }), + message({ role: 'user', content: 'second' }) + ] + const display = toDisplayMessages(rows) + expect(display.map((m) => [m.role, m.content])).toEqual([ + ['user', 'first'], + ['assistant', ''], + ['tool', 'Used search tool'], + ['assistant', 'done'], + ['user', 'second'] + ]) + expect(display[1]).toMatchObject({ reasoning: 'why' }) + expect(display[1]).not.toHaveProperty('streaming') + + const { chat } = fakeChat(idleState({ messages: rows })) + const host = new FlowChatViewHost(chat) + host.retryRequest(4) + await vi.waitFor(() => + expect(chat.sendMessage).toHaveBeenCalledWith('second', expect.anything()) + ) + host.dispose() + }) + it('does not flag a turn whose tool failed but whose agent still answered', () => { const display = toDisplayMessages([ message({ role: 'user', content: 'try' }), @@ -114,6 +167,50 @@ describe('toDisplayMessages', () => { expect(display[0]).toMatchObject({ role: 'user', error: undefined }) }) + it('offers no retry while the turn whose tool failed is still running', () => { + const messages = [ + message({ role: 'user', content: 'first' }), + message({ role: 'assistant', content: 'boom', success: false }), + message({ role: 'user', content: 'try' }), + message({ + role: 'tool', + content: 'Error executing search', + success: false, + tool: { name: 'search', status: 'error' } + }) + ] + const running = toDisplayMessages(messages, true) + expect(running[0]).toMatchObject({ role: 'user', error: true }) + expect(running[2]).toMatchObject({ role: 'user', error: undefined }) + expect(toDisplayMessages(messages, false)[2]).toMatchObject({ role: 'user', error: true }) + }) + + it('shows what a failed tool returned as its error, not the row label', () => { + const display = toDisplayMessages([ + message({ + role: 'tool', + content: 'Error executing lookup_stock', + success: false, + tool: { + name: 'lookup_stock', + status: 'error', + result: '{"message":"stock service unavailable","name":"Error","stack":"at main"}' + } + }), + message({ + role: 'tool', + content: 'Error executing mcp_search', + success: false, + tool: { name: 'mcp_search', status: 'error', result: 'connection refused' } + }) + ]) + expect(display[0]).toMatchObject({ + content: 'Error executing lookup_stock', + error: 'stock service unavailable' + }) + expect(display[1]).toMatchObject({ error: 'connection refused' }) + }) + it('flags the streaming assistant message and a failed tool', () => { const display = toDisplayMessages([ message({ role: 'assistant', content: 'partial', pending: true }), @@ -257,6 +354,59 @@ describe('FlowChatViewHost', () => { host.dispose() }) + it('offers no retry on a turn the reader stopped', () => { + const failedTool = message({ + role: 'tool', + content: 'Error executing search', + success: false, + tool: { name: 'search', status: 'error' } + }) + const { chat, set } = fakeChat( + idleState({ + status: 'streaming', + messages: [message({ id: 'live', role: 'user', content: 'go' }), failedTool] + }) + ) + const host = new FlowChatViewHost(chat) + host.cancel() + expect(chat.stop).toHaveBeenCalled() + set({ status: 'idle' }) + expect(host.displayMessages[0]).toMatchObject({ role: 'user', error: undefined }) + // The chat re-reads the rows: the user message gets its row id, and the cancelled + // flow's failure lands as the answer. + const cancelled = message({ role: 'assistant', content: 'Job canceled', success: false }) + set({ + messages: [ + message({ id: 'live', serverId: 'row-2', role: 'user', content: 'go' }), + failedTool, + cancelled + ] + }) + expect(host.displayMessages[0]).toMatchObject({ role: 'user', error: undefined }) + // Reopened, with an older page that failed in front: messages are rebuilt from rows. + set({ + messages: [ + message({ id: 'row-0', serverId: 'row-0', role: 'user', content: 'before' }), + message({ role: 'assistant', content: 'boom', success: false }), + message({ id: 'row-2', serverId: 'row-2', role: 'user', content: 'go' }), + failedTool, + cancelled + ] + }) + expect(host.displayMessages[0]).toMatchObject({ content: 'before', error: true }) + expect(host.displayMessages[2]).toMatchObject({ content: 'go', error: undefined }) + // The next turn is not stopped and fails on its own. + set({ + messages: [ + ...chat.getState().messages, + message({ role: 'user', content: 'again' }), + message({ role: 'assistant', content: 'boom', success: false }) + ] + }) + expect(host.displayMessages.at(-2)).toMatchObject({ role: 'user', error: true }) + host.dispose() + }) + it('stops following the chat once disposed', () => { const { chat, set } = fakeChat() const host = new FlowChatViewHost(chat) From 5371519f0f5ce7750982dcdb374dca72115902e7 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 17 Sep 2026 16:24:30 +0200 Subject: [PATCH 19/22] feat: let plan mode search and read connected mcp servers (#11205) Co-authored-by: Claude Opus 5 --- .../lib/components/copilot/chat/global/mcpTools.test.ts | 6 ++++++ .../src/lib/components/copilot/chat/global/mcpTools.ts | 8 +++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/mcpTools.test.ts b/frontend/src/lib/components/copilot/chat/global/mcpTools.test.ts index 9e1b46589c..30a4403746 100644 --- a/frontend/src/lib/components/copilot/chat/global/mcpTools.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/mcpTools.test.ts @@ -127,6 +127,12 @@ describe('read/write split', () => { expect(getTool('call_mcp_write_tool').requiresConfirmation).toBe(true) }) + it('admits search and reads in plan mode, never writes', () => { + expect(getTool('search_mcp_tools').planModeSafe).toBe(true) + expect(getTool('call_mcp_read_tool').planModeSafe).toBe(true) + expect(getTool('call_mcp_write_tool').planModeSafe).toBeFalsy() + }) + // The rejection sends the model to the write tool, which classifies from the // same cached listing: without dropping it, that retry is refused too and the // model has nowhere to go until the entry expires. diff --git a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts index c1db1cac1b..90afa0b04b 100644 --- a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts @@ -345,8 +345,9 @@ const callMcpToolSchema = z.object({ /** * The read and write call tools differ only in which side of the `readOnlyHint` - * split they accept, and that check is what keeps a mutating call behind the - * user's confirmation — building both from one body keeps them from drifting. + * split they accept, and that check is what keeps a mutating call out of plan + * mode and behind the user's confirmation — building both from one body keeps + * them from drifting. */ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}> { const isRead = mode === 'read' @@ -360,7 +361,7 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}> ), showDetails: true, ...(isRead - ? {} + ? { planModeSafe: true } : { requiresConfirmation: true, confirmationMessage: (args: any) => `Call ${args?.tool ?? ''} on ${args?.server ?? ''}` @@ -429,6 +430,7 @@ export function createMcpTools(servers: McpServer[]): Tool<{}>[] { 'search_mcp_tools', 'Search the tools exposed by the MCP servers connected to this workspace (listed in the system prompt). Returns server + tool names to pass to call_mcp_read_tool or call_mcp_write_tool, each with the input schema its arguments must follow.' ), + planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = searchMcpToolsSchema.parse(args) toolCallbacks.setToolStatus(toolId, { content: 'Searching MCP tools...' }) From d440ebb52055ab9e53b3c297b155cebe3e86bfe7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 17 Sep 2026 20:10:04 +0200 Subject: [PATCH 20/22] chore(main): release 1.814.0 (#11171) * chore(main): release 1.814.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 26 ++ backend/Cargo.lock | 249 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++-- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- chat-sdk/package-lock.json | 4 +- chat-sdk/package.json | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 22 files changed, 197 insertions(+), 172 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3a7939e0cc..103817a597 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.813.0" + ".": "1.814.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd3b7f784..9a5ee31990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [1.814.0](https://github.com/windmill-labs/windmill/compare/v1.813.0...v1.814.0) (2026-09-17) + + +### Features + +* **ai-chat:** add list_workers and list_data_metrics global tools ([#11143](https://github.com/windmill-labs/windmill/issues/11143)) ([e954d33](https://github.com/windmill-labs/windmill/commit/e954d33613e4ff5027667eb8f646615d9bbd499d)) +* **ai-chat:** merge get_job_logs and get_flow_run_details into get_run ([#11172](https://github.com/windmill-labs/windmill/issues/11172)) ([5bb37ca](https://github.com/windmill-labs/windmill/commit/5bb37ca3388666fba72c55534e37f37bb3e9299e)) +* allow git sync auto-pull, promotion and PRs on Pro licenses ([#11173](https://github.com/windmill-labs/windmill/issues/11173)) ([02e47de](https://github.com/windmill-labs/windmill/commit/02e47de8b4c4f3f54753aabf8c67bc8e71ffb957)) +* badge chat-input flows on the home list ([#11164](https://github.com/windmill-labs/windmill/issues/11164)) ([3d08197](https://github.com/windmill-labs/windmill/commit/3d0819718221f885b61e73d02b43dcc853c7d02a)) +* collect flow conversations and agent memory once their last message goes ([#11178](https://github.com/windmill-labs/windmill/issues/11178)) ([23c24a9](https://github.com/windmill-labs/windmill/commit/23c24a9688d4c8c462f53221334d538280f16bca)) +* flow chat model picker on a shared model-settings component ([#11187](https://github.com/windmill-labs/windmill/issues/11187)) ([189793c](https://github.com/windmill-labs/windmill/commit/189793c2e4db7f1c853695ebcc895c1ec82ed19f)) +* keep flow inputs and seed the agent when chat mode is enabled ([#11177](https://github.com/windmill-labs/windmill/issues/11177)) ([68f2248](https://github.com/windmill-labs/windmill/commit/68f2248018fc218a090bf939e1eb22ff97d5bc22)) +* let plan mode search and read connected mcp servers ([#11205](https://github.com/windmill-labs/windmill/issues/11205)) ([5371519](https://github.com/windmill-labs/windmill/commit/5371519f0f5ce7750982dcdb374dca72115902e7)) +* let test_run_flow name the conversation of a chat-mode test run ([#11198](https://github.com/windmill-labs/windmill/issues/11198)) ([6e1ef93](https://github.com/windmill-labs/windmill/commit/6e1ef93f329cb396ffc3df3304d592e8fa0e0e71)) +* managed memory with an inherited or custom memory id per step ([#11118](https://github.com/windmill-labs/windmill/issues/11118)) ([c297ed0](https://github.com/windmill-labs/windmill/commit/c297ed0052d998fb8f063faa2a36c6eb03e327be)) +* render the flow chat through the shared session chat components ([#11175](https://github.com/windmill-labs/windmill/issues/11175)) ([a9ec0ae](https://github.com/windmill-labs/windmill/commit/a9ec0aec3ac0c6b0f7919d0eb2168816923826d7)) +* show flow step detail inside the graph tab on narrow detail layouts ([#11168](https://github.com/windmill-labs/windmill/issues/11168)) ([64dffe6](https://github.com/windmill-labs/windmill/commit/64dffe6106ad6a55b61a423c855a4b5b0cef533e)) +* store mcp tool call, result and reasoning on flow conversation rows ([#11176](https://github.com/windmill-labs/windmill/issues/11176)) ([a571117](https://github.com/windmill-labs/windmill/commit/a571117f3fd2cef14c920770645c60ee358fdfdd)) +* tell test flow conversations from deployed ones and rename a chat ([#11179](https://github.com/windmill-labs/windmill/issues/11179)) ([4eab995](https://github.com/windmill-labs/windmill/commit/4eab995cf7cf091a5e4640da4cb77e0921bb7fdf)) + + +### Bug Fixes + +* disable a schedule whose cron has no run left instead of panicking ([#11195](https://github.com/windmill-labs/windmill/issues/11195)) ([381d447](https://github.com/windmill-labs/windmill/commit/381d4470ef699ea82283742132e56556b95d2bd2)) +* skip expiry notifications for app embed and SDK tokens ([#11169](https://github.com/windmill-labs/windmill/issues/11169)) ([9d348f8](https://github.com/windmill-labs/windmill/commit/9d348f84c7830f36b6153472556fd70e3d84cd24)) + ## [1.813.0](https://github.com/windmill-labs/windmill/compare/v1.812.0...v1.813.0) (2026-09-16) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b2b9d65c97..5f5d5f2151 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -728,7 +728,7 @@ dependencies = [ "futures-lite 2.6.1", "parking", "polling 3.11.0", - "rustix 1.1.4", + "rustix 1.1.5", "slab", "windows-sys 0.61.2", ] @@ -873,7 +873,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1976,7 +1976,7 @@ dependencies = [ "prettyplease 0.3.0", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -2000,7 +2000,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -2144,7 +2144,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -2164,9 +2164,9 @@ dependencies = [ [[package]] name = "bytes-str" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577d2bf5650f8554d5a372af5ac93535110a0fc75b3e702bb853369febf227c2" +checksum = "4dde6d05e75a31ec9610eb6446a6f0a10dd30ff5100d720fee4c7c7a9008b5ba" dependencies = [ "bytes", "serde", @@ -2338,9 +2338,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -2454,7 +2454,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3047,7 +3047,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3102,7 +3102,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -4580,7 +4580,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users 0.5.3", "windows-sys 0.61.2", ] @@ -4603,7 +4603,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -4850,7 +4850,7 @@ checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -5198,7 +5198,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.59.0", ] @@ -5319,7 +5319,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -9438,7 +9438,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.5.3", "pin-project-lite", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -9574,7 +9574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -10235,11 +10235,10 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", "thiserror 2.0.20", ] @@ -10261,7 +10260,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -10580,7 +10579,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -10798,9 +10797,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags 2.13.2", "errno", @@ -11217,7 +11216,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals 0.30.0", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -11416,7 +11415,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -11438,7 +11437,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -11492,7 +11491,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -11560,7 +11559,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -12708,9 +12707,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -12745,7 +12744,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -13036,7 +13035,7 @@ dependencies = [ "fastrand 2.5.0", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -13055,7 +13054,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -13115,7 +13114,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -14075,7 +14074,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -14179,9 +14178,9 @@ checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-normalization" @@ -14546,7 +14545,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "wasm-bindgen-shared", ] @@ -14589,7 +14588,7 @@ checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -14794,7 +14793,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-nats", @@ -14882,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.813.0" +version = "1.814.0" dependencies = [ "async-stream", "async-trait", @@ -14916,7 +14915,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14929,7 +14928,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "argon2", @@ -15069,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15092,7 +15091,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15109,7 +15108,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15135,7 +15134,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.813.0" +version = "1.814.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15145,7 +15144,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15162,7 +15161,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15184,7 +15183,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15207,7 +15206,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15223,7 +15222,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15245,7 +15244,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15267,7 +15266,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15281,7 +15280,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-nats", @@ -15316,7 +15315,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15341,7 +15340,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15369,7 +15368,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15391,7 +15390,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15411,7 +15410,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15449,7 +15448,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15477,7 +15476,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.813.0" +version = "1.814.0" dependencies = [ "lazy_static", "serde", @@ -15489,7 +15488,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.813.0" +version = "1.814.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15513,7 +15512,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15527,7 +15526,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.813.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15562,7 +15561,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.813.0" +version = "1.814.0" dependencies = [ "chrono", "lazy_static", @@ -15576,7 +15575,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15595,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.813.0" +version = "1.814.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15702,7 +15701,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.813.0" +version = "1.814.0" dependencies = [ "chrono", "futures", @@ -15722,7 +15721,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.813.0" +version = "1.814.0" dependencies = [ "regex", "serde", @@ -15739,7 +15738,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15766,7 +15765,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "futures", @@ -15783,7 +15782,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.813.0" +version = "1.814.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15799,7 +15798,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -15820,7 +15819,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -15851,7 +15850,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "arc-swap", @@ -15876,7 +15875,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-stream", @@ -15911,7 +15910,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "futures", @@ -15929,7 +15928,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.813.0" +version = "1.814.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15938,7 +15937,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -15950,7 +15949,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -15962,7 +15961,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "gosyn", @@ -15974,7 +15973,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -15986,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -15998,7 +15997,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "nu-parser", @@ -16009,7 +16008,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16020,7 +16019,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16032,7 +16031,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16043,7 +16042,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-recursion", @@ -16065,7 +16064,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -16077,7 +16076,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -16091,7 +16090,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16108,7 +16107,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -16121,7 +16120,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde", @@ -16133,7 +16132,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -16151,7 +16150,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16167,7 +16166,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16183,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -16197,7 +16196,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-recursion", @@ -16236,7 +16235,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "const_format", @@ -16276,7 +16275,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.813.0" +version = "1.814.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16287,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-recursion", @@ -16322,7 +16321,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16346,7 +16345,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16379,7 +16378,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16406,7 +16405,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16439,7 +16438,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16459,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16493,7 +16492,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16529,7 +16528,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16552,7 +16551,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16576,7 +16575,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-nats", @@ -16600,7 +16599,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16635,7 +16634,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16663,7 +16662,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16688,7 +16687,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16707,7 +16706,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-once-cell", @@ -16825,7 +16824,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.813.0" +version = "1.814.0" dependencies = [ "bytes", "futures", @@ -17458,7 +17457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -17519,7 +17518,7 @@ checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "synstructure 0.14.0", ] @@ -17560,7 +17559,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "synstructure 0.14.0", ] @@ -17616,7 +17615,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f4f529e0fb..395e49afdc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.813.0" +version = "1.814.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.813.0" +version = "1.814.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 9ec3e74012..335b3f79cf 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.813.0" +version = "1.814.0" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.813.0" +version = "1.814.0" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.813.0" +version = "1.814.0" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.813.0" +version = "1.814.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index d5b9ed591b..1262be6a3b 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.813.0" +version = "1.814.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8447666a94..364e8ef674 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.813.0 + version: 1.814.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index cb408fb4e4..55735ed9fd 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.813.0"; +export const VERSION = "v1.814.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 5d72fb23b3..83475ba1c8 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.813.0", + "version": "1.814.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.813.0", + "version": "1.814.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index eb286df9b3..57df9050f6 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.813.0", + "version": "1.814.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 1ad44a8df9..a399ec1b85 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.813.0"; +export const VERSION = "1.814.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e95243b8d0..935a1aa475 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.813.0", + "version": "1.814.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.813.0", + "version": "1.814.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 93369b167e..eedca8f2c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.813.0", + "version": "1.814.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 9e536726b6..054896a5ce 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.813.0" +wmill = ">=1.814.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index c9b87fef66..73c11300d7 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.813.0 + version: 1.814.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 40dc8152f9..7abe5afa7c 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.813.0' + ModuleVersion = '1.814.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index c80f2acc65..ad591ded27 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.813.0" +version = "1.814.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 926b2c38a0..753b906793 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.813.0", + "version": "1.814.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 10632523b9..327a37e3f5 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.813.0", + "version": "1.814.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 878d8933d7..9f38cdf20a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.813.0 +1.814.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 7e52e0a7e2..0c9e94cd0a 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.813.0", + "version": "1.814.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.813.0", + "version": "1.814.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 3fe7c82591..7a15686549 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.813.0", + "version": "1.814.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From d1a25360b070994a31fb033707dbfde1fb553939 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 18 Sep 2026 08:22:59 +0200 Subject: [PATCH 21/22] perf: count completed jobs on the v2_job_completed index with a timeout (#11211) * perf: count completed jobs on the v2_job_completed index with a timeout Co-Authored-By: Claude Opus 5 (1M context) * test: cover the count_jobs tags filter on completed jobs Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/tests/list_jobs.rs | 60 ++++++++++++++++++++++++++ backend/windmill-api/src/jobs.rs | 72 +++++++++++++++++++++----------- 2 files changed, 107 insertions(+), 25 deletions(-) diff --git a/backend/tests/list_jobs.rs b/backend/tests/list_jobs.rs index 82a9e197cc..29a49c1610 100644 --- a/backend/tests/list_jobs.rs +++ b/backend/tests/list_jobs.rs @@ -1199,3 +1199,63 @@ async fn test_wm_labels_from_result_merged_with_static_labels( Ok(()) } + +/// `tag` lives only on `v2_job`, which count_jobs joins only when `tags` is set. +#[sqlx::test(fixtures("base"))] +async fn test_count_completed_jobs_tags_filter(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + + for (ws, tag, status) in [ + ("test-workspace", "deno", "success"), + ("test-workspace", "deno", "failure"), + ("test-workspace", "python3", "success"), + ("other-workspace", "deno", "success"), + ] { + let id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO v2_job (id, workspace_id, tag) VALUES ($1, $2, $3)") + .bind(id) + .bind(ws) + .bind(tag) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, status, duration_ms) VALUES ($1, $2, $3::job_status, 0)", + ) + .bind(id) + .bind(ws) + .bind(status) + .execute(&db) + .await?; + } + + for (query, expected) in [ + ("", 3), + ("tags=deno", 2), + ("tags=deno&success=true", 1), + ("tags=deno,python3&completed_after_s_ago=3600", 3), + ] { + let response = client + .client() + .get(format!( + "{}/w/test-workspace/jobs/completed/count_jobs?{query}", + client.baseurl() + )) + .send() + .await?; + assert!( + response.status().is_success(), + "{query}: {}", + response.text().await? + ); + assert_eq!(response.json::().await?, expected, "{query}"); + } + + Ok(()) +} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 4149292200..eb77cb6e36 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -4369,12 +4369,12 @@ async fn count_completed_jobs_detail( Query(query): Query, ) -> error::JsonResult { let mut sqlb = SqlBuilder::select_from("v2_job_completed"); - //FOR RLS - sqlb.join("v2_job USING (id)"); sqlb.field("COUNT(*) as count"); + // Filtering on v2_job.workspace_id instead would keep the planner off + // ix_job_workspace_id_completed_at_all and scan the whole retention window. if !(w_id == "admins" && query.all_workspaces.unwrap_or(false)) { - sqlb.and_where_eq("v2_job.workspace_id", "?".bind(&w_id)); + sqlb.and_where_eq("v2_job_completed.workspace_id", "?".bind(&w_id)); } if let Some(after_s_ago) = query.completed_after_s_ago { @@ -4393,6 +4393,7 @@ async fn count_completed_jobs_detail( } if let Some(tags) = query.tags { + sqlb.join("v2_job USING (id)"); sqlb.and_where_in( "v2_job.tag", &tags.split(",").map(|t| quote(t)).collect::>(), @@ -4400,7 +4401,19 @@ async fn count_completed_jobs_detail( } let sql = sqlb.sql()?; - let stats = sqlx::query_scalar::<_, i64>(&sql).fetch_one(&db).await?; + let mut tx = db.begin().await?; + set_list_jobs_statement_timeout(&mut tx).await?; + let stats = sqlx::query_scalar::<_, i64>(&sql) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + list_jobs_timeout_error( + e, + "Counting completed jobs", + "Lower completed_after_s_ago or narrow the filters.", + ) + })?; + tx.commit().await?; Ok(Json(stats)) } @@ -4429,6 +4442,33 @@ lazy_static::lazy_static! { .unwrap_or(30); } +/// A client that gives up does not cancel its query, so without this bound every retry of a +/// slow filter stacks another scan running until the connection-wide 5min timeout. +async fn set_list_jobs_statement_timeout(tx: &mut Transaction<'_, Postgres>) -> error::Result<()> { + let timeout_secs = *LIST_JOBS_STATEMENT_TIMEOUT_SECS; + if timeout_secs > 0 { + sqlx::query(&format!("SET LOCAL statement_timeout = '{timeout_secs}s'")) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +fn list_jobs_timeout_error(e: sqlx::Error, action: &str, hint: &str) -> Error { + let timeout_secs = *LIST_JOBS_STATEMENT_TIMEOUT_SECS; + match e { + sqlx::Error::Database(ref db_err) + if timeout_secs > 0 && db_err.code().as_deref() == Some("57014") => + { + Error::Generic( + StatusCode::BAD_REQUEST, + format!("{action} took more than {timeout_secs}s and was stopped. {hint}"), + ) + } + e => e.into(), + } +} + async fn list_jobs( authed: ApiAuthed, Extension(user_db): Extension, @@ -4545,32 +4585,14 @@ async fn list_jobs( }; // tracing::info!("sql: {}", &sql); let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?; - - // A client that gives up does not cancel its query, so without this bound every retry of a - // slow filter stacks another scan running until the connection-wide 5min timeout. - let timeout_secs = *LIST_JOBS_STATEMENT_TIMEOUT_SECS; - if timeout_secs > 0 { - sqlx::query(&format!("SET LOCAL statement_timeout = '{timeout_secs}s'")) - .execute(&mut *tx) - .await?; - } + set_list_jobs_statement_timeout(&mut tx).await?; let jobs: Vec = sqlx::query_as(&sql) .fetch_all(&mut *tx) .warn_after_seconds_with_sql(5, format!("list_jobs: {}", sql)) .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) - if timeout_secs > 0 && db_err.code().as_deref() == Some("57014") => - { - Error::Generic( - StatusCode::BAD_REQUEST, - format!( - "Listing jobs took more than {timeout_secs}s and was stopped. Set a start date or narrow the filters." - ), - ) - } - e => e.into(), + .map_err(|e| { + list_jobs_timeout_error(e, "Listing jobs", "Set a start date or narrow the filters.") })?; tx.commit().await?; From c4c9677982b75c63d98ebf85b1904e0c341ba957 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Fri, 18 Sep 2026 10:16:10 +0200 Subject: [PATCH 22/22] feat: attach files to a flow chat message (#11185) * feat: attach files to a flow chat message Co-Authored-By: Claude Opus 5 (1M context) * fix: store chat uploads under windmill_uploads and withdraw refused sends cleanly Co-Authored-By: Claude Opus 5 (1M context) * fix: refuse extra files for a single-file input and keep attachments on retry Co-Authored-By: Claude Opus 5 (1M context) * fix: clean up partial upload batches, withdraw a stopped send once, free retry payloads Co-Authored-By: Claude Opus 5 (1M context) * fix: list a chat conversation only once its run starts and require files where the flow does Co-Authored-By: Claude Opus 5 (1M context) * feat: carry uploaded attachments on the pending chat user message Co-Authored-By: Claude Opus 5 (1M context) * fix: route rich-composer file paste through the attachment lanes and tighten comments Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a stopped turn's files for retry and let an explicit media type win Co-Authored-By: Claude Opus 5 (1M context) * fix: refuse chat attachments sent without text and shorten comments Co-Authored-By: Claude Opus 5 (1M context) * fix: resolve the attachments input from real flow input reads, ignoring loop iteration Co-Authored-By: Claude Opus 5 (1M context) * refactor: read flow input references through one parser for the model and attachments controls Co-Authored-By: Claude Opus 5 (1M context) * fix: withdraw an attachment send stopped after its uploads answered Co-Authored-By: Claude Opus 5 (1M context) * fix: discard uploads when a send is stopped as its attachments are announced Co-Authored-By: Claude Opus 5 (1M context) * docs: keep uploadAttachments' doc comment on uploadAttachments Co-Authored-By: Claude Opus 5 (1M context) * refactor: never delete chat uploads from workspace storage Co-Authored-By: Claude Opus 5 (1M context) * test: pin that a failed upload aborts the rest of its batch Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- chat-sdk/README.md | 32 +- chat-sdk/src/api.ts | 28 +- chat-sdk/src/attachments.ts | 116 +++++ chat-sdk/src/chat.ts | 102 +++- chat-sdk/src/index.ts | 4 + chat-sdk/src/types.ts | 37 +- chat-sdk/test/attachments.test.ts | 444 ++++++++++++++++++ chat-sdk/test/support.ts | 7 +- .../InputTransformSchemaForm.svelte | 4 +- .../copilot/chat/AIChatDisplay.svelte | 71 ++- .../copilot/chat/AIChatInput.svelte | 220 ++++++++- .../copilot/chat/AIChatManager.svelte.ts | 5 + .../copilot/chat/QueuedMessageChip.svelte | 19 +- .../components/copilot/chat/blobUtils.test.ts | 19 + .../lib/components/copilot/chat/blobUtils.ts | 58 +++ .../components/copilot/chat/chatViewHost.ts | 27 +- .../copilot/chat/messageDraft.svelte.ts | 46 +- .../copilot/chat/messageDraft.test.ts | 31 +- .../flows/content/AiAgentStepInputs.svelte | 4 +- .../flows/conversations/FlowChat.svelte | 3 +- .../conversations/FlowChatInterface.svelte | 36 +- .../agentAttachmentInput.test.ts | 179 +++++++ .../conversations/agentAttachmentInput.ts | 185 ++++++++ .../conversations/agentChatInputs.test.ts | 9 + .../flows/conversations/agentChatInputs.ts | 67 +-- .../conversations/flowChatViewHost.svelte.ts | 211 +++++++-- .../conversations/flowChatViewHost.test.ts | 190 +++++++- .../components/inputTransformEnv.svelte.ts | 29 +- 28 files changed, 1996 insertions(+), 187 deletions(-) create mode 100644 chat-sdk/src/attachments.ts create mode 100644 chat-sdk/test/attachments.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/blobUtils.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/blobUtils.ts create mode 100644 frontend/src/lib/components/flows/conversations/agentAttachmentInput.test.ts create mode 100644 frontend/src/lib/components/flows/conversations/agentAttachmentInput.ts diff --git a/chat-sdk/README.md b/chat-sdk/README.md index 0f1082de45..1d09b68265 100644 --- a/chat-sdk/README.md +++ b/chat-sdk/README.md @@ -183,7 +183,7 @@ await chat.sendMessage('Hello') | `workspace` | Detected inside a raw app. | | `token` | A token, or a function returning one (called before every request, so it can fetch a short-lived token from your backend). Omit it inside a raw app. | | `history` | `'server'`, `'local'` or `'none'`, see [History](#history). Defaults to `'server'` with a viewer session and `'local'` with an explicit `token`. | -| `inputs` | Extra flow inputs sent with every message. `sendMessage(text, { inputs })` adds per-message ones. | +| `inputs` | Extra flow inputs sent with every message. `sendMessage(text, { inputs })` adds per-message ones, and `{ attachments, attachmentsInput }` files, see [Attachments](#attachments). | | `storageKey` | Namespace for `local` history, e.g. the signed-in user's id. Local history is per browser and per flow; without it, users sharing a browser share it. | | `fetch`, `storage` | Replacements for the globals, for tests and unusual runtimes. | | `pageSize` | Messages and conversations per page of server history. Default 50. | @@ -226,8 +226,8 @@ A turn goes `submitted` (the flow is queued) → `streaming` (the answer is arri answer, an `assistant` message with `success: false`. `status: 'error'` (with `error` set) means the turn could not run or be followed at all, such as a refused request. -Methods: `sendMessage(text, { inputs? })`, `stop()`, `newConversation()`, -`selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`, +Methods: `sendMessage(text, { inputs?, attachments?, attachmentsInput? })`, `stop()`, +`newConversation()`, `selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`, `deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`, `destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` carries @@ -235,6 +235,32 @@ own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` stops following the current answer; the flow keeps running and, with server history, its answer is there when you come back. +## Attachments + +A flow whose AI agent step reads `user_attachments` from an `s3object[]` (or a single +`s3object`) flow input takes files with a message: + +```ts +await chat.sendMessage('What does this contract say?', { + attachments: [{ name: file.name, data: file }], // a Blob/File, or a `data:` URL + attachmentsInput: { name: 'files', multiple: true } +}) +``` + +Each file is uploaded to the workspace's object storage under +`windmill_uploads/chat///` and handed to the input as `{ s3, filename }` +objects (the object for a single-file input). Once the uploads return, the pending user +message lists them in `attachments`, as `{ input, s3, filename }` references. The name's +extension is corrected to the file's media type for PNG, JPEG and PDF, because the worker +reads the type off the key. +Files need message text to go with them. A failed upload rejects `sendMessage` before any +run starts, and `stop()` during the upload aborts it; both leave the transcript as it was. +The chat never deletes uploads, so files of a send that did not run stay in storage. The +workspace needs object storage set up. With Enterprise advanced storage permissions, the +user needs read and write on `windmill_uploads/*`, which the default rules grant. The upload goes through +`job_helpers`, so a restricted token needs `job_helpers:write`; a sandboxed raw app cannot +request that scope today, so attachments are not available there yet. + ## History Windmill stores every conversation of a chat-mode flow, and each Windmill user sees diff --git a/chat-sdk/src/api.ts b/chat-sdk/src/api.ts index 3ec5b0f4ff..a14e9800a1 100644 --- a/chat-sdk/src/api.ts +++ b/chat-sdk/src/api.ts @@ -230,6 +230,27 @@ export class WindmillChatApi { return (await res.json()) as FlowConversationMessage[] } + /** + * Puts bytes in the workspace's object storage under `fileKey` and returns the key they were + * stored under (the server may rewrite it). Needs the workspace to have object storage set. + */ + async uploadFile( + fileKey: string, + body: Blob, + options: { contentType?: string; signal?: AbortSignal } = {} + ): Promise<{ file_key: string }> { + const query: Record = { file_key: fileKey } + if (options.contentType) query.content_type = options.contentType + const res = await this.#request('job_helpers/upload_s3_file', { + method: 'POST', + query, + raw: body, + contentType: options.contentType || 'application/octet-stream', + signal: options.signal + }) + return (await res.json()) as { file_key: string } + } + async deleteConversation(conversationId: string): Promise { await this.#request(`flow_conversations/delete/${encodeURIComponent(conversationId)}`, { method: 'DELETE' @@ -241,7 +262,11 @@ export class WindmillChatApi { init: { method?: string query?: Record + /** JSON-encoded. */ body?: unknown + /** Sent as is, under `contentType`. */ + raw?: Blob + contentType?: string accept?: string signal?: AbortSignal } = {} @@ -252,13 +277,14 @@ export class WindmillChatApi { const headers: Record = {} if (init.accept) headers['Accept'] = init.accept if (init.body !== undefined) headers['Content-Type'] = 'application/json' + else if (init.raw !== undefined) headers['Content-Type'] = init.contentType ?? 'application/octet-stream' const token = typeof this.#token === 'function' ? await this.#token() : this.#token if (token) headers['Authorization'] = `Bearer ${token}` const res = await this.#fetch(url.toString(), { method: init.method ?? 'GET', headers, - body: init.body === undefined ? undefined : JSON.stringify(init.body), + body: init.body === undefined ? init.raw : JSON.stringify(init.body), // A token must not be paired with ambient cookies; without one, the cookie is // the credential and only rides same-origin requests. credentials: token ? 'omit' : 'same-origin', diff --git a/chat-sdk/src/attachments.ts b/chat-sdk/src/attachments.ts new file mode 100644 index 0000000000..ac5e71c7d3 --- /dev/null +++ b/chat-sdk/src/attachments.ts @@ -0,0 +1,116 @@ +import type { WindmillChatApi } from './api' +import type { AttachmentUpload } from './types' +import { abortError, isAbortError } from './utils' + +/** + * Where a chat's uploads live in the workspace's object storage. Under `windmill_uploads/` + * because the default Enterprise storage permissions grant every user write and read there + * and deny any other top-level prefix: a key outside it is refused for non-admins, both on + * upload and when the agent's job reads the file back. + */ +export const CHAT_UPLOADS_PREFIX = 'windmill_uploads/chat' + +/** What an AI agent step reads out of `user_attachments`. */ +export interface UploadedAttachment { + s3: string + filename: string +} + +/** + * The extension each type must be stored under. The worker reads an attachment's media type + * from the key's extension only (`mime_guess` in `windmill-ai/src/image_handler.rs`, falling + * back to `image/png`), never from the stored content type, so the extension must be true. + */ +const EXTENSION_BY_MEDIA_TYPE: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'application/pdf': 'pdf' +} + +/** + * The name an attachment is stored under: the picked name with the extension its media type + * needs, e.g. a `photo.webp` re-encoded to PNG becomes `photo.png`. Other types keep their name. + */ +export function storedAttachmentName(filename: string, mediaType: string): string { + const extension = EXTENSION_BY_MEDIA_TYPE[mediaType] + if (!extension) return filename + const stem = filename.replace(/\.[^./]+$/, '') + return `${stem || filename}.${extension}` +} + +/** The bytes of an attachment as a Blob carrying its media type. */ +export function attachmentBlob(attachment: AttachmentUpload): Blob { + const data = + typeof attachment.data === 'string' + ? dataUrlToBlob(attachment.data, 'application/octet-stream') + : attachment.data + return attachment.mediaType && attachment.mediaType !== data.type + ? new Blob([data], { type: attachment.mediaType }) + : data +} + +function dataUrlToBlob(dataUrl: string, fallbackType: string): Blob { + const comma = dataUrl.indexOf(',') + if (!dataUrl.startsWith('data:') || comma === -1) { + throw new Error('windmill-chat: an attachment given as a string must be a data: URL') + } + const header = dataUrl.slice(5, comma) + const isBase64 = header.endsWith(';base64') + const mediaType = (isBase64 ? header.slice(0, -';base64'.length) : header) || fallbackType + const payload = dataUrl.slice(comma + 1) + if (!isBase64) return new Blob([decodeURIComponent(payload)], { type: mediaType }) + const binary = atob(payload) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: mediaType }) +} + +/** + * Put each attachment in the workspace's object storage and hand back what the agent reads. + * The key's turn prefix and per-file index keep two files with the same name, in this turn or + * an earlier one, from overwriting each other; the name stays the last segment. + */ +export async function uploadAttachments( + api: WindmillChatApi, + attachments: AttachmentUpload[], + turnId: string, + signal?: AbortSignal +): Promise { + const prefix = `${CHAT_UPLOADS_PREFIX}/${turnId}` + // One failed upload aborts the rest. Nothing already stored is deleted: the chat never + // removes objects from the workspace's storage, so a send that does not run leaves them. + if (signal?.aborted) throw abortError() + const batch = new AbortController() + const abortBatch = () => batch.abort() + signal?.addEventListener('abort', abortBatch, { once: true }) + try { + const results = await Promise.allSettled( + attachments.map(async (attachment, index) => { + try { + const blob = attachmentBlob(attachment) + const filename = storedAttachmentName( + attachment.name || `attachment-${index + 1}`, + blob.type + ) + const { file_key } = await api.uploadFile(`${prefix}/${index}/${filename}`, blob, { + contentType: blob.type, + signal: batch.signal + }) + return { s3: file_key, filename } + } catch (e) { + batch.abort() + throw e + } + }) + ) + const reasons = results.flatMap((r) => (r.status === 'rejected' ? [r.reason] : [])) + // A stop that lands once every upload has answered still withdraws the batch. + if (reasons.length === 0 && !signal?.aborted) { + return results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : [])) + } + // The failure that started it, not the aborts it caused in the other uploads. + throw reasons.find((reason) => !isAbortError(reason)) ?? reasons[0] ?? abortError() + } finally { + signal?.removeEventListener('abort', abortBatch) + } +} diff --git a/chat-sdk/src/chat.ts b/chat-sdk/src/chat.ts index b13fa89173..2e2c52f3df 100644 --- a/chat-sdk/src/chat.ts +++ b/chat-sdk/src/chat.ts @@ -8,6 +8,7 @@ import { import { resolveConfig, type ResolvedConfig } from './config' import { followJob } from './follow' import { createLocalHistory, type LocalHistory } from './history' +import { uploadAttachments } from './attachments' import type { AgentStreamEvent } from './stream' import type { Chat, @@ -15,6 +16,7 @@ import type { ChatOptions, ChatState, Conversation, + SendMessageOptions, ToolInvocation } from './types' import { @@ -22,6 +24,7 @@ import { truncateTitle, errorResultMessage, extractChatAnswer, + abortError, isAbortError, isErrorResult, now, @@ -41,6 +44,12 @@ interface Turn { conversationId: string /** Id of the turn's user message; the answer is whatever follows it. */ userMessageId: string + /** The turn opened the conversation; withdrawing it closes the conversation again. */ + isNew: boolean + /** The run was asked for. Before that, a failure or a stop withdraws the turn instead of failing it. */ + started: boolean + /** Both `stop()` and the send's own rejection withdraw; only the first may. */ + withdrawn: boolean jobId?: string /** The flow job and its step jobs; a persisted answer carries one of them as `job_id`. */ jobIds?: Set @@ -101,21 +110,34 @@ class ChatImpl implements Chat { } } - sendMessage = async ( - text: string, - options: { inputs?: Record } = {} - ): Promise => { + sendMessage = async (text: string, options: SendMessageOptions = {}): Promise => { const content = text.trim() - if (!content) return + if (!content) { + // A run needs a message; files alone would otherwise be dropped without a word. + if (options.attachments?.length) throw new Error('windmill-chat: attachments need a message to go with them') + return + } if (this.#turn) { throw new Error('windmill-chat: a message is already being answered; call stop() first') } + const attachments = options.attachments ?? [] + const attachmentsInput = options.attachmentsInput + if (attachments.length > 0 && !attachmentsInput) { + throw new Error('windmill-chat: attachments need `attachmentsInput`, the flow input that takes them') + } + if (attachmentsInput && !attachmentsInput.multiple && attachments.length > 1) { + // Uploading all of them would run with the first and leave the rest stranded in storage. + throw new Error(`windmill-chat: \`${attachmentsInput.name}\` holds one file; got ${attachments.length}`) + } const isNew = this.#state.conversationId === undefined const conversationId = this.#state.conversationId ?? randomId() const turn: Turn = { controller: new AbortController(), conversationId, userMessageId: `pending-${randomId()}`, + isNew, + started: false, + withdrawn: false, streamedText: false } this.#turn = turn @@ -127,7 +149,6 @@ class ChatImpl implements Chat { const touched = { ...conversation, updatedAt: timestamp } this.#set({ conversationId, - conversations: [touched, ...this.#state.conversations.filter((c) => c.id !== conversationId)], messages: [ ...this.#state.messages, { id: turn.userMessageId, role: 'user', content, success: true, createdAt: timestamp, pending: true } @@ -135,10 +156,32 @@ class ChatImpl implements Chat { status: 'submitted', error: undefined }) - this.#rememberConversation() try { - const args = { ...this.#config.inputs, ...options.inputs, user_message: content } + const args: Record = { ...this.#config.inputs, ...options.inputs, user_message: content } + if (attachmentsInput && attachments.length > 0) { + // Uploaded with the turn already shown as submitted: the message is in the transcript + // and `stop()` can abort the upload, while a second send is refused as usual. + const uploaded = await uploadAttachments(this.#api, attachments, randomId(), turn.controller.signal) + args[attachmentsInput.name] = attachmentsInput.multiple ? uploaded : uploaded[0] + // Shown on the pending message until its server row replaces it, carrying its own. + const carried = uploaded.map((u) => ({ input: attachmentsInput.name, s3: u.s3, filename: u.filename })) + if (this.#turnActive(turn)) { + this.#set({ + messages: this.#state.messages.map((m) => (m.id === turn.userMessageId ? { ...m, attachments: carried } : m)) + }) + } + } + // Nothing may start once stop() or a conversation switch has withdrawn the turn, including + // a stop from a subscriber told of the attachments just above. + if (turn.controller.signal.aborted) throw abortError() + turn.started = true + // Listed only once the run is asked for: a send that never runs (an upload that failed + // or was stopped) then has no conversation entry to take back. + this.#set({ + conversations: [touched, ...this.#state.conversations.filter((c) => c.id !== conversationId)] + }) + this.#rememberConversation() const context = { memoryId: conversationId, conversationId, signal: turn.controller.signal } turn.jobId = this.#config.run ? await this.#config.run(args, context) @@ -152,6 +195,13 @@ class ChatImpl implements Chat { } await this.#finishTurn(turn, result, isNew) } catch (e) { + if (!turn.started) { + // Nothing ran: the message is withdrawn rather than shown as a failed turn, and the + // caller gets the reason (an upload that failed, or the AbortError of a stop()). + if (this.#turn === turn) this.#turn = undefined + this.#withdrawTurn(turn) + throw e + } // stop() and a conversation switch abort the turn and settle the state themselves. if (turn.controller.signal.aborted || isAbortError(e)) return this.#failTurn(turn, e) @@ -164,6 +214,12 @@ class ChatImpl implements Chat { const turn = this.#turn if (!turn) return this.#detachTurn() + if (!turn.started) { + // Still uploading its attachments: there is no run to cancel, and the message the + // reader took back must not stay in the transcript as sent. + this.#withdrawTurn(turn) + return + } if (this.#state.conversationId === turn.conversationId) { this.#set({ messages: finalized(this.#state.messages), status: 'idle' }) this.#persistLocal() @@ -578,6 +634,36 @@ class ChatImpl implements Chat { } } + /** + * Take back the user message of a turn that never ran. A conversation it would have opened + * was never listed (see `sendMessage`), so only the message goes, and, while it is the turn + * on screen, the busy status. A switch away mid-upload has already written the message to + * local history, so it is removed there too. + */ + #withdrawTurn(turn: Turn): void { + if (turn.withdrawn) return + turn.withdrawn = true + const id = turn.conversationId + const withoutTurn = (messages: ChatMessage[]) => messages.filter((m) => m.id !== turn.userMessageId) + if (this.#state.conversationId === id) { + const messages = withoutTurn(this.#state.messages) + // A turn started since, such as a resend right after Stop, owns the status. + const newerTurn = this.#turn !== undefined && this.#turn !== turn + if (newerTurn) { + this.#set({ messages }) + } else { + const unopened = turn.isNew && messages.length === 0 + this.#set({ messages, status: 'idle', error: undefined, ...(unopened ? { conversationId: undefined } : {}) }) + } + this.#persistLocal() + } + if (this.#state.history === 'local' && this.#state.conversationId !== id) { + const stored = withoutTurn(this.#local.getMessages(id)) + if (stored.length > 0) this.#local.saveMessages(id, stored) + else if (!this.#state.conversations.some((c) => c.id === id)) this.#local.deleteConversation(id) + } + } + #failTurn(turn: Turn, e: unknown): void { if (!this.#turnActive(turn)) return const error = toError(e) diff --git a/chat-sdk/src/index.ts b/chat-sdk/src/index.ts index aedaa8b19f..9ed2ed0b7e 100644 --- a/chat-sdk/src/index.ts +++ b/chat-sdk/src/index.ts @@ -14,7 +14,10 @@ export { export { parseStreamEvents, createStreamEventParser, type AgentStreamEvent } from './stream' export { followJob, type FollowEvent } from './follow' export { extractChatAnswer, conversationIdFor } from './utils' +export { storedAttachmentName, uploadAttachments, CHAT_UPLOADS_PREFIX, type UploadedAttachment } from './attachments' export type { + AttachmentsInput, + AttachmentUpload, Chat, ChatAttachment, ChatMessage, @@ -25,6 +28,7 @@ export type { Conversation, FetchLike, HistoryMode, + SendMessageOptions, StorageLike, TokenSource, ToolInvocation diff --git a/chat-sdk/src/types.ts b/chat-sdk/src/types.ts index 87fd5a7b0a..99e16fe304 100644 --- a/chat-sdk/src/types.ts +++ b/chat-sdk/src/types.ts @@ -127,12 +127,45 @@ export interface ChatOptions { onError?: (error: Error, turn: { conversationId: string; jobId?: string }) => void } +/** A file sent with a message. It is uploaded to the workspace's object storage before the run starts. */ +export interface AttachmentUpload { + /** Kept as the last segment of the stored key, its extension corrected to the media type for PNG, JPEG and PDF. */ + name: string + /** The bytes: a Blob, or a `data:` URL of them. */ + data: Blob | string + /** The file's media type. Defaults to the Blob's own type, or the data URL's. */ + mediaType?: string +} + +/** The flow input the uploaded attachments are handed to: an `s3object` (`multiple: false`) or an `s3object[]`. */ +export interface AttachmentsInput { + name: string + multiple: boolean +} + +export interface SendMessageOptions { + /** Extra flow inputs for this message, on top of `ChatOptions.inputs`. */ + inputs?: Record + /** + * Files to upload and hand to the flow as `{ s3, filename }` objects in `attachmentsInput`, + * the way an AI agent step reads `user_attachments`. A failed upload rejects `sendMessage` + * and the run never starts; `stop()` during the upload does the same with an `AbortError`. + */ + attachments?: AttachmentUpload[] + /** Required with `attachments`, which also need message text. With `multiple: false`, more than one attachment is refused before anything uploads. */ + attachmentsInput?: AttachmentsInput +} + export interface Chat { getState(): ChatState /** Calls `listener` now and on every change; returns the unsubscribe function (Svelte store contract). */ subscribe(listener: (state: ChatState) => void): () => void - /** Sends a message in the current conversation, starting one when there is none. Resolves when the answer is complete. */ - sendMessage(text: string, options?: { inputs?: Record }): Promise + /** + * Sends a message in the current conversation, starting one when there is none. Resolves + * when the answer is complete. Rejects when the message could not be sent at all — a turn + * already running, an attachment that failed to upload — without touching the transcript. + */ + sendMessage(text: string, options?: SendMessageOptions): Promise /** Stops following the answer and asks Windmill to cancel the run. */ stop(): Promise newConversation(): void diff --git a/chat-sdk/test/attachments.test.ts b/chat-sdk/test/attachments.test.ts new file mode 100644 index 0000000000..9c2f577567 --- /dev/null +++ b/chat-sdk/test/attachments.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, test } from 'bun:test' +import { WindmillChatApi } from '../src/api' +import { storedAttachmentName, uploadAttachments } from '../src/attachments' +import { createChat } from '../src/chat' +import type { ChatOptions } from '../src/types' +import { abortError } from '../src/utils' +import { fetchMock, json, memoryStorage, sse, text, type RecordedCall, type Route } from './support' + +const BASE = 'http://wm.test' +const FLOW = 'f/chat/agent' +const UPLOAD_PATH = '/api/w/ws/job_helpers/upload_s3_file' + +const run: Route = (c) => + c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` + ? text('job-1') + : undefined + +/** Stores under the key it was asked to, like the server with a `file_key`. */ +const upload: Route = (c) => + c.url.pathname === UPLOAD_PATH + ? json({ file_key: c.url.searchParams.get('file_key') }) + : undefined + +const answer: Route = (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-1' + ? sse([ + { + type: 'update', + completed: true, + only_result: { output: 'ok', messages: [] } + } + ]) + : undefined + +function options(fetch: ChatOptions['fetch']): ChatOptions { + return { + flowPath: FLOW, + baseUrl: BASE, + workspace: 'ws', + token: 'tok', + fetch, + storage: memoryStorage() + } +} + +const uploads = (calls: RecordedCall[]) => calls.filter((c) => c.url.pathname === UPLOAD_PATH) +const runs = (calls: RecordedCall[]) => + calls.filter((c) => c.url.pathname.startsWith('/api/w/ws/jobs/run/')) + +const png = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { + type: 'image/png' +}) +const pdf = new Blob(['%PDF-1.7'], { type: 'application/pdf' }) + +describe('storedAttachmentName', () => { + // The worker reads the media type from the key's extension, so it has to match the bytes. + test('renames a re-encoded image and gives a bare name its extension', () => { + expect(storedAttachmentName('photo.webp', 'image/png')).toBe('photo.png') + expect(storedAttachmentName('holiday.png', 'image/jpeg')).toBe('holiday.jpg') + expect(storedAttachmentName('contract', 'application/pdf')).toBe('contract.pdf') + expect(storedAttachmentName('report.2026.final.webp', 'image/png')).toBe( + 'report.2026.final.png' + ) + }) + + test('leaves a type it does not know alone', () => { + expect(storedAttachmentName('notes.csv', 'text/csv')).toBe('notes.csv') + }) +}) + +describe('sendMessage with attachments', () => { + test('uploads each file under the turn prefix and hands the list to the input', async () => { + const { fetch, calls } = fetchMock(upload, run, answer) + const chat = createChat(options(fetch)) + + await chat.sendMessage('read these', { + inputs: { locale: 'fr' }, + attachments: [ + { name: 'photo.webp', data: png }, + { name: 'contract', data: pdf }, + // A data URL is decoded to its bytes; the mediaType names what they are. + { + name: 'photo.webp', + data: `data:image/png;base64,${btoa('\x89PNG')}` + } + ], + attachmentsInput: { name: 'files', multiple: true } + }) + + const keys = uploads(calls).map((c) => c.url.searchParams.get('file_key')!) + expect(keys).toHaveLength(3) + const prefix = keys[0].split('/').slice(0, 3).join('/') + expect(prefix).toMatch(/^windmill_uploads\/chat\/[0-9a-f-]{36}$/) + expect(keys).toEqual([ + `${prefix}/0/photo.png`, + `${prefix}/1/contract.pdf`, + `${prefix}/2/photo.png` + ]) + expect(uploads(calls).map((c) => c.url.searchParams.get('content_type'))).toEqual([ + 'image/png', + 'application/pdf', + 'image/png' + ]) + expect(uploads(calls).map((c) => c.headers['content-type'])).toEqual([ + 'image/png', + 'application/pdf', + 'image/png' + ]) + expect(new Uint8Array(await uploads(calls)[2].raw!.arrayBuffer())).toEqual( + new Uint8Array([0x89, 0x50, 0x4e, 0x47]) + ) + + expect(runs(calls)[0].body).toEqual({ + locale: 'fr', + user_message: 'read these', + files: [ + { s3: `${prefix}/0/photo.png`, filename: 'photo.png' }, + { s3: `${prefix}/1/contract.pdf`, filename: 'contract.pdf' }, + { s3: `${prefix}/2/photo.png`, filename: 'photo.png' } + ] + }) + expect(chat.getState().status).toBe('idle') + }) + + test('hands a single object to an input that holds one file', async () => { + const { fetch, calls } = fetchMock(upload, run, answer) + const chat = createChat(options(fetch)) + await chat.sendMessage('read this', { + attachments: [{ name: 'contract.pdf', data: pdf }], + attachmentsInput: { name: 'file', multiple: false } + }) + const body = runs(calls)[0].body as Record + expect(body.file).toEqual({ + s3: expect.stringMatching(/\/0\/contract\.pdf$/), + filename: 'contract.pdf' + }) + }) + + test('refuses several files for an input that holds one, before uploading any', async () => { + const { fetch, calls } = fetchMock(upload, run, answer) + const chat = createChat(options(fetch)) + await expect( + chat.sendMessage('read these', { + attachments: [ + { name: 'a.pdf', data: pdf }, + { name: 'b.png', data: png } + ], + attachmentsInput: { name: 'file', multiple: false } + }) + ).rejects.toThrow('holds one file') + expect(calls).toHaveLength(0) + expect(chat.getState().messages).toEqual([]) + }) + + test('refuses attachments without message text, before uploading', async () => { + const { fetch, calls } = fetchMock(upload, run, answer) + const chat = createChat(options(fetch)) + await expect( + chat.sendMessage(' ', { + attachments: [{ name: 'a.pdf', data: pdf }], + attachmentsInput: { name: 'files', multiple: true } + }) + ).rejects.toThrow('need a message') + expect(calls).toHaveLength(0) + }) + + test('refuses attachments without an input to put them in', async () => { + const { fetch, calls } = fetchMock(upload, run, answer) + const chat = createChat(options(fetch)) + await expect( + chat.sendMessage('hi', { attachments: [{ name: 'a.pdf', data: pdf }] }) + ).rejects.toThrow('attachmentsInput') + expect(calls).toHaveLength(0) + }) + + test('a failed upload rejects without a run, and withdraws the message', async () => { + const { fetch, calls } = fetchMock( + (c) => (c.url.pathname === UPLOAD_PATH ? text('no object storage', 500) : undefined), + run, + answer + ) + const chat = createChat(options(fetch)) + const statuses: string[] = [] + chat.subscribe((s) => statuses.push(s.status)) + + await expect( + chat.sendMessage('read this', { + attachments: [{ name: 'contract.pdf', data: pdf }], + attachmentsInput: { name: 'files', multiple: true } + }) + ).rejects.toThrow('no object storage') + + expect(runs(calls)).toHaveLength(0) + // Shown as submitted while uploading, then withdrawn whole: no message, no conversation. + expect(statuses).toContain('submitted') + const state = chat.getState() + expect(state.status).toBe('idle') + expect(state.messages).toEqual([]) + expect(state.conversationId).toBeUndefined() + expect(state.conversations).toEqual([]) + // The chat is free for the next message. + await chat.sendMessage('plain') + expect(runs(calls)).toHaveLength(1) + }) + + test('stop() during the upload aborts it and withdraws the message', async () => { + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === UPLOAD_PATH + ? new Promise((_, reject) => + c.signal!.addEventListener('abort', () => reject(abortError())) + ) + : undefined, + run, + answer + ) + const chat = createChat(options(fetch)) + const sending = chat.sendMessage('read this', { + attachments: [{ name: 'contract.pdf', data: pdf }], + attachmentsInput: { name: 'files', multiple: true } + }) + await new Promise((r) => setTimeout(r, 0)) + expect(chat.getState().status).toBe('submitted') + await chat.stop() + await expect(sending).rejects.toMatchObject({ name: 'AbortError' }) + expect(runs(calls)).toHaveLength(0) + expect(chat.getState()).toMatchObject({ + status: 'idle', + messages: [], + conversationId: undefined + }) + }) + + test('a switch away mid-upload leaves no conversation behind', async () => { + const storage = memoryStorage() + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === UPLOAD_PATH + ? new Promise((_, reject) => + c.signal!.addEventListener('abort', () => reject(abortError())) + ) + : undefined, + run, + answer + ) + const chat = createChat({ ...options(fetch), storage }) + const sending = chat.sendMessage('never runs', { + attachments: [{ name: 'contract.pdf', data: pdf }], + attachmentsInput: { name: 'files', multiple: true } + }) + await new Promise((r) => setTimeout(r, 0)) + const opened = chat.getState().conversationId! + chat.newConversation() + await expect(sending).rejects.toMatchObject({ name: 'AbortError' }) + + expect(runs(calls)).toHaveLength(0) + expect(chat.getState().conversations.map((c) => c.id)).not.toContain(opened) + const reloaded = createChat({ ...options(fetch), storage }) + expect((await reloaded.loadConversations()).map((c) => c.id)).not.toContain(opened) + }) + + test('a failed upload aborts the rest of its batch and deletes nothing', async () => { + let first: (r: Response) => void = () => {} + const { fetch, calls } = fetchMock( + (c) => { + if (c.url.pathname !== UPLOAD_PATH) return undefined + const key = c.url.searchParams.get('file_key')! + // The first file lands after the second has already failed. + if (key.includes('/0/')) return new Promise((resolve) => (first = resolve)) + setTimeout(() => first(json({ file_key: keys()[0] })), 5) + return text('quota exceeded', 507) + }, + run, + answer + ) + const keys = () => uploads(calls).map((c) => c.url.searchParams.get('file_key')!) + const chat = createChat(options(fetch)) + await expect( + chat.sendMessage('read these', { + attachments: [ + { name: 'a.pdf', data: pdf }, + { name: 'b.png', data: png } + ], + attachmentsInput: { name: 'files', multiple: true } + }) + ).rejects.toThrow('quota exceeded') + // The upload still in flight when the other failed was told to stop. + expect(uploads(calls)[0].signal?.aborted).toBe(true) + expect(calls.filter((c) => c.method === 'DELETE')).toEqual([]) + expect(runs(calls)).toHaveLength(0) + }) + + test('a send made right after stop() is not reset by the stopped upload', async () => { + let releaseRun: (r: Response) => void = () => {} + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === UPLOAD_PATH + ? new Promise((_, reject) => + c.signal!.addEventListener('abort', () => reject(abortError())) + ) + : undefined, + (c) => + c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` + ? new Promise((resolve) => (releaseRun = resolve)) + : undefined, + answer + ) + const chat = createChat(options(fetch)) + // An existing conversation, so the stopped turn and the next one share it. + const first = chat.sendMessage('first') + await new Promise((r) => setTimeout(r, 0)) + releaseRun(text('job-1')) + await first + const stopped = chat.sendMessage('with a file', { + attachments: [{ name: 'a.pdf', data: pdf }], + attachmentsInput: { name: 'files', multiple: true } + }) + await new Promise((r) => setTimeout(r, 0)) + void chat.stop() + const next = chat.sendMessage('right after') + await expect(stopped).rejects.toMatchObject({ name: 'AbortError' }) + await new Promise((r) => setTimeout(r, 0)) + expect(chat.getState().status).toBe('submitted') + expect(chat.getState().messages.map((m) => m.content)).toContain('right after') + expect(chat.getState().messages.map((m) => m.content)).not.toContain('with a file') + releaseRun(text('job-1')) + await next + expect(runs(calls)).toHaveLength(2) + }) + + test('a conversation is listed only once its run starts', async () => { + let failUpload: (r: Response) => void = () => {} + const { fetch } = fetchMock( + (c) => + c.url.pathname === UPLOAD_PATH + ? new Promise((resolve) => (failUpload = resolve)) + : undefined, + run, + answer + ) + const chat = createChat(options(fetch)) + const sending = chat.sendMessage('read this', { + attachments: [{ name: 'a.pdf', data: pdf }], + attachmentsInput: { name: 'files', multiple: true } + }) + await new Promise((r) => setTimeout(r, 0)) + expect(chat.getState()).toMatchObject({ status: 'submitted', conversations: [] }) + failUpload(text('boom', 500)) + await expect(sending).rejects.toThrow('boom') + expect(chat.getState().conversations).toEqual([]) + }) + + test('an already aborted signal uploads nothing', async () => { + const { fetch, calls } = fetchMock(upload) + const api = new WindmillChatApi({ baseUrl: BASE, workspace: 'ws', token: 'tok', fetch }) + const controller = new AbortController() + controller.abort() + await expect( + uploadAttachments(api, [{ name: 'a.pdf', data: pdf }], 'turn', controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(calls).toHaveLength(0) + }) + + test('the pending user message carries its uploaded files before the run returns', async () => { + let releaseRun: (r: Response) => void = () => {} + const { fetch, calls } = fetchMock( + upload, + (c) => + c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` + ? new Promise((resolve) => (releaseRun = resolve)) + : undefined, + answer + ) + const chat = createChat(options(fetch)) + const sending = chat.sendMessage('read these', { + attachments: [ + { name: 'photo.webp', data: png }, + { name: 'contract', data: pdf } + ], + attachmentsInput: { name: 'files', multiple: true } + }) + while (runs(calls).length === 0) await new Promise((r) => setTimeout(r, 1)) + const keys = uploads(calls).map((c) => c.url.searchParams.get('file_key')!) + const pending = chat.getState().messages.find((m) => m.role === 'user')! + expect(pending.pending).toBe(true) + expect(pending.attachments).toEqual([ + { input: 'files', s3: keys[0], filename: 'photo.png' }, + { input: 'files', s3: keys[1], filename: 'contract.pdf' } + ]) + releaseRun(text('job-1')) + await sending + }) + + test('an explicit mediaType wins over the type a data URL declares', async () => { + const { fetch, calls } = fetchMock(upload, run, answer) + const chat = createChat(options(fetch)) + await chat.sendMessage('read this', { + attachments: [ + { + name: 'contract', + data: `data:application/octet-stream;base64,${btoa('%PDF')}`, + mediaType: 'application/pdf' + } + ], + attachmentsInput: { name: 'files', multiple: true } + }) + const call = uploads(calls)[0] + expect(call.url.searchParams.get('file_key')).toMatch(/\/0\/contract\.pdf$/) + expect(call.url.searchParams.get('content_type')).toBe('application/pdf') + }) + + test('stop() after the uploads land but before the run starts runs nothing', async () => { + const { fetch, calls } = fetchMock(upload, run, answer) + const chat = createChat(options(fetch)) + const sending = chat.sendMessage('read this', { + attachments: [{ name: 'contract.pdf', data: pdf }], + attachmentsInput: { name: 'files', multiple: true } + }) + // The upload responds at once; Stop lands before the send resumes after it. + while (uploads(calls).length === 0) await Promise.resolve() + await chat.stop() + await expect(sending).rejects.toMatchObject({ name: 'AbortError' }) + expect(runs(calls)).toHaveLength(0) + expect(calls.filter((c) => c.method === 'DELETE')).toEqual([]) + expect(chat.getState()).toMatchObject({ status: 'idle', messages: [], conversations: [] }) + }) + + test('a subscriber stopping when the attachments appear runs nothing', async () => { + const { fetch, calls } = fetchMock(upload, run, answer) + const chat = createChat(options(fetch)) + chat.subscribe((s) => { + if (s.messages.some((m) => m.attachments)) void chat.stop() + }) + await expect( + chat.sendMessage('read this', { + attachments: [{ name: 'contract.pdf', data: pdf }], + attachmentsInput: { name: 'files', multiple: true } + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(runs(calls)).toHaveLength(0) + expect(calls.filter((c) => c.method === 'DELETE')).toEqual([]) + expect(chat.getState()).toMatchObject({ status: 'idle', messages: [], conversations: [] }) + }) +}) diff --git a/chat-sdk/test/support.ts b/chat-sdk/test/support.ts index c794e50f3d..e198f19355 100644 --- a/chat-sdk/test/support.ts +++ b/chat-sdk/test/support.ts @@ -5,6 +5,9 @@ export interface RecordedCall { url: URL headers: Record body: unknown + /** A body sent as is rather than as JSON (an upload). */ + raw?: Blob + signal?: AbortSignal } export type Route = (call: RecordedCall) => Response | Promise | undefined @@ -20,7 +23,9 @@ export function fetchMock(...routes: Route[]): { fetch: FetchLike; calls: Record headers: Object.fromEntries( Object.entries((init?.headers as Record) ?? {}).map(([k, v]) => [k.toLowerCase(), v]) ), - body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + raw: init?.body instanceof Blob ? init.body : undefined, + signal: init?.signal ?? undefined } calls.push(call) for (const route of routes) { diff --git a/frontend/src/lib/components/InputTransformSchemaForm.svelte b/frontend/src/lib/components/InputTransformSchemaForm.svelte index 465e173f1f..31f2eb0054 100644 --- a/frontend/src/lib/components/InputTransformSchemaForm.svelte +++ b/frontend/src/lib/components/InputTransformSchemaForm.svelte @@ -8,7 +8,7 @@ import type { PickableProperties } from './flows/previousResults' import InputTransformForm from './InputTransformForm.svelte' import InputTransformPickers from './InputTransformPickers.svelte' - import { useS3StorageConfigured } from './inputTransformEnv.svelte' + import { useWorkspaceStorageConfigured } from './inputTransformEnv.svelte' import type ItemPicker from './ItemPicker.svelte' import type VariableEditor from './VariableEditor.svelte' import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte' @@ -86,7 +86,7 @@ let itemPicker: ItemPicker | undefined = $state(undefined) let variableEditor: VariableEditor | undefined = $state(undefined) - const s3Storage = useS3StorageConfigured(() => ws) + const s3Storage = useWorkspaceStorageConfigured(() => ws) let keys: string[] = $state([]) $effect(() => { diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 82dcbc2dcd..0085ca701f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -361,7 +361,15 @@ chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP ) - const canAttachFiles = $derived(chatHost.supportsMessageAttachments && !disabled) + // Why attaching is off, when this chat takes attachments but cannot right now. The `+` is + // kept and disabled rather than dropped: the input is the composer's either way, so the + // reader has to be able to see here why nothing can be attached. + const attachmentsOffReason = $derived( + chatHost.supportsMessageAttachments ? chatHost.attachmentsUnavailableReason : undefined + ) + const canAttachFiles = $derived( + chatHost.supportsMessageAttachments && !disabled && !attachmentsOffReason + ) // Folders are linked as session-wide assets, which only a host that reads files in // the browser can do — a host running the turn server-side takes attachments only. const canLinkFolders = $derived(chatHost.supportsLinkedFolders && !disabled) @@ -430,24 +438,32 @@ return Array.from(e.dataTransfer?.types ?? []).includes('Files') } + // A drop is claimed while attaching is off for a stated reason, too: the browser would + // otherwise navigate to the dropped file, and the reader is owed the reason instead. + const panelTakesDrops = $derived(canAttachFiles || attachmentsOffReason !== undefined) + function onPanelDragEnter(e: DragEvent) { - if (!canAttachFiles || !dragHasFiles(e)) return + if (!panelTakesDrops || !dragHasFiles(e)) return e.preventDefault() dragDepth++ } function onPanelDragOver(e: DragEvent) { - if (!canAttachFiles || !dragHasFiles(e)) return + if (!panelTakesDrops || !dragHasFiles(e)) return e.preventDefault() if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' } function onPanelDragLeave(_e: DragEvent) { - if (!canAttachFiles) return + if (!panelTakesDrops) return dragDepth = Math.max(0, dragDepth - 1) } async function onPanelDrop(e: DragEvent) { dragDepth = 0 - if (!canAttachFiles || !dragHasFiles(e)) return + if (!panelTakesDrops || !dragHasFiles(e)) return e.preventDefault() + if (attachmentsOffReason) { + sendUserToast(attachmentsOffReason, true) + return + } const dt = e.dataTransfer if (!dt) return // Images and loose text files attach to the message; folders link as session @@ -484,9 +500,8 @@ handles.length === 0 ? flatFiles : await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile())) - // Loose text files attach to the message, like images. - const textFiles = looseFiles.filter((f) => !isImageFile(f)) - if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles) + // Loose files attach to the message, like images. + await attachNonImageFiles(looseFiles.filter((f) => !isImageFile(f))) // Folders link as a live handle. const dirs = handles.filter(isDirectoryHandle) if (dirs.length > 0 && !canLinkFolders) { @@ -523,24 +538,31 @@ if (canLinkFolders) await handleAddFiles(folderEntries) else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true) } - if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText) + await attachNonImageFiles(topLevelText) } } async function onFileInputChange(e: Event) { const input = e.currentTarget as HTMLInputElement if (input.files && input.files.length > 0) { - const picked = Array.from(input.files) - const imageFiles = picked.filter(isImageFile) - const textFiles = picked.filter((f) => !isImageFile(f)) - // Reserved before the text work is awaited — see onPanelDrop. - const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined - if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles) - await imageWork + await attachPickedFiles(Array.from(input.files)) } input.value = '' // allow re-selecting the same file } + async function attachNonImageFiles(files: File[]) { + await aiChatInput?.addNonImageFiles(files) + } + + async function attachPickedFiles(picked: File[]) { + const imageFiles = picked.filter(isImageFile) + const others = picked.filter((f) => !isImageFile(f)) + // Reserved before the other work is awaited — see onPanelDrop. + const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined + await attachNonImageFiles(others) + await imageWork + } + function onFolderInputChange(e: Event) { const input = e.currentTarget as HTMLInputElement // webkitdirectory files carry webkitRelativePath (`folder/sub/file`); addFiles groups @@ -622,6 +644,7 @@ const showFooterLeftControls = $derived( !footerMessageShown && (canAttachFiles || + attachmentsOffReason !== undefined || showContextPicker || showAutonomyModeSelector || (chatHost.mode === AIMode.SCRIPT && hasDiff)) @@ -991,7 +1014,21 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/snippet} {/if} - {#if canAttachFiles} + {#if attachmentsOffReason} + +