diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2d6bef08fe..103817a597 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.812.0" + ".": "1.814.0" } 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/CHANGELOG.md b/CHANGELOG.md index 49005a22a0..9a5ee31990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # 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) + + +### Features + +* back AI sessions up to the workspace object storage ([#11116](https://github.com/windmill-labs/windmill/issues/11116)) ([796b6e5](https://github.com/windmill-labs/windmill/commit/796b6e5297d8cceb842ec097f33ec1c3115058bd)) +* delete a browser's copy of an AI session past its workspace retention ([#11156](https://github.com/windmill-labs/windmill/issues/11156)) ([a48ae65](https://github.com/windmill-labs/windmill/commit/a48ae656ae59d600311f81ef357d08df5226515a)) +* rename saved agents from the agent editor and flag broken links ([#11147](https://github.com/windmill-labs/windmill/issues/11147)) ([57a99f6](https://github.com/windmill-labs/windmill/commit/57a99f66a88f195cac8f583b59d69d627cc1ec1d)) +* retention for AI sessions on the object store and in the browser ([#11152](https://github.com/windmill-labs/windmill/issues/11152)) ([ee6d317](https://github.com/windmill-labs/windmill/commit/ee6d317e318fa8a1506fb18d51c45b627070627a)) +* return an ai agent step's thinking in its job result ([#11140](https://github.com/windmill-labs/windmill/issues/11140)) ([c4e878e](https://github.com/windmill-labs/windmill/commit/c4e878e8313a72a16bfbe81fbb3935ee7728ec6f)) +* stream reasoning summaries in AI agent Responses API steps ([#11124](https://github.com/windmill-labs/windmill/issues/11124)) ([b51c0ea](https://github.com/windmill-labs/windmill/commit/b51c0eabbe774c78a9cbf824a3df6528d970b8f6)) + + +### Bug Fixes + +* **apps:** re-check access in place after a password sign-in ([#11166](https://github.com/windmill-labs/windmill/issues/11166)) ([49d0310](https://github.com/windmill-labs/windmill/commit/49d0310ecc08040b6c6fa4f402584543d679e2e8)) +* **apps:** run-mode inline app component uses only pinned content ([#11135](https://github.com/windmill-labs/windmill/issues/11135)) ([781b5a5](https://github.com/windmill-labs/windmill/commit/781b5a57e81eb721d97d7b87e23dd84f23895400)) +* **cli:** keep the workspace color when settings are synced from git ([#11144](https://github.com/windmill-labs/windmill/issues/11144)) ([129c045](https://github.com/windmill-labs/windmill/commit/129c04559548cd1bcf67758ec416fb2a48e7b928)) +* **cli:** resolve lockgen imports through modules a push leaves alone ([#11160](https://github.com/windmill-labs/windmill/issues/11160)) ([54553b2](https://github.com/windmill-labs/windmill/commit/54553b2add6941395f03ca34ee24cf335a3b23c2)) +* dispatch workflow-as-code tasks from a deployed flow's inline step ([#11146](https://github.com/windmill-labs/windmill/issues/11146)) ([e8078f2](https://github.com/windmill-labs/windmill/commit/e8078f2a963166b09849650424583f5dcfd28a84)) +* keep sidebar confirmation dialogs from being confined to the rail ([#11158](https://github.com/windmill-labs/windmill/issues/11158)) ([b9b5988](https://github.com/windmill-labs/windmill/commit/b9b5988ebdf3edd75add445282977c516ef5ed51)) +* keep the instance users table's actions and header in view ([#11145](https://github.com/windmill-labs/windmill/issues/11145)) ([a9a9335](https://github.com/windmill-labs/windmill/commit/a9a9335a34a13ffd8cd2699adc92087b679548ca)) +* stop reading an array job result as wm_failure or http response ([#11154](https://github.com/windmill-labs/windmill/issues/11154)) ([9a8a9c4](https://github.com/windmill-labs/windmill/commit/9a8a9c480cf008761ec6ceaff694085a252a32ae)) +* walk the whole fork ancestry for app installations and fork conflicts ([#11151](https://github.com/windmill-labs/windmill/issues/11151)) ([73dc892](https://github.com/windmill-labs/windmill/commit/73dc892f9c9c840a5f0fb12fcbb28bbe0f38795f)) +* **worker:** bound cache transfers and import fetches in bun jobs ([#11138](https://github.com/windmill-labs/windmill/issues/11138)) ([31c4325](https://github.com/windmill-labs/windmill/commit/31c43255fdcc827c3fdf65e40238f8f3a83201cd)) + ## [1.812.0](https://github.com/windmill-labs/windmill/compare/v1.811.1...v1.812.0) (2026-09-15) 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/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/backendPreview.ts b/ai_evals/adapters/frontend/backendPreview.ts index 57e1cfdf2a..8c1837c808 100644 --- a/ai_evals/adapters/frontend/backendPreview.ts +++ b/ai_evals/adapters/frontend/backendPreview.ts @@ -1,5 +1,5 @@ -import { randomUUID } from 'node:crypto' import type { BackendValidationSettings } from '../../core/backendValidation' +import { buildWorkspaceId } from './workspaceId' interface CompletedJobResultMaybe { completed: boolean @@ -24,7 +24,6 @@ export interface CompletedPreviewJob { const tokenCache = new Map>() const sharedWorkspaceQueue = new Map>() const managedSharedWorkspacePrefixes = ['f/evals/'] -const DEFAULT_WORKSPACE_PREFIX = 'ai-evals' export class BackendPreviewClient { constructor(private readonly settings: BackendValidationSettings) {} @@ -441,16 +440,6 @@ async function withSharedWorkspaceLock(workspaceId: string, body: () => Promi } } -function buildWorkspaceId(caseId: string, attempt: number): string { - const caseSlug = caseId - .toLowerCase() - .replace(/[^a-z0-9-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 30) - const suffix = randomUUID().slice(0, 8) - return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || 'case'}-a${attempt}-${suffix}` -} - function extractFolderName(path: string): string | null { if (!path.startsWith('f/')) { return null diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 6d80f19197..d27bb4d69a 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -11,6 +11,7 @@ import type { Script } from '../../../frontend/src/lib/gen' import type { + DataMetric, DataTableTables, DataTableTableSchema, EndpointTool, @@ -92,7 +93,7 @@ export interface BenchmarkWorkspaceResource { } export interface BenchmarkWorkspaceJob { - /** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */ + /** Stable id so a case prompt can reference a specific run (e.g. for get_run). */ id?: string jobKind?: CompletedJob['job_kind'] scriptPath?: string @@ -100,6 +101,8 @@ export interface BenchmarkWorkspaceJob { label?: string success?: boolean logs?: string + args?: Record + result?: unknown } export interface BenchmarkWorkspaceRunnables { @@ -110,6 +113,9 @@ export interface BenchmarkWorkspaceRunnables { aiProviders?: BenchmarkWorkspaceAiProvider[] resources?: BenchmarkWorkspaceResource[] datatables?: BenchmarkDatatableSeed[] + /** DuckLake catalog names, as `list_ducklakes` reports them. */ + ducklakes?: string[] + dataMetrics?: DataMetric[] jobs?: BenchmarkWorkspaceJob[] } @@ -156,7 +162,7 @@ export function registerBenchmarkWorkspaceRunnables( ...runnables, datatables: runnables.datatables ? structuredClone(runnables.datatables) : undefined }) - // Seed any fixture jobs so list_runs / get_job_logs have data to return. + // Seed any fixture jobs so list_runs / get_run have data to return. for (const seed of runnables.jobs ?? []) { createBenchmarkCompletedJob({ workspace, @@ -166,7 +172,9 @@ export function registerBenchmarkWorkspaceRunnables( scriptPath: seed.scriptPath, createdBy: seed.createdBy, label: seed.label, - logs: seed.logs + logs: seed.logs, + args: seed.args, + result: seed.result }) } } @@ -481,6 +489,33 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string { return job.logs ?? '' } +/** + * Mirror `JobService.getFlowAllResults`, which get_run calls for the execution + * tree. Fixture jobs are single runs with no steps, so only the root entry. + */ +export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { + const job = getBenchmarkCompletedJob(workspace, jobId) + if (!job) { + throw new Error(`Job "${jobId}" not found in benchmark workspace`) + } + return { + entries: [ + { + job_id: jobId, + label: 'Flow', + kind: job.job_kind ?? 'script', + depth: 0, + sibling_index: 1, + sibling_count: 1, + status: job.success ? 'success' : 'failure', + success: job.success + } + ], + truncated: false, + scope_filtered: false + } +} + // ============= Drafts (per-user, DB-backed in production) ============= /** @@ -642,6 +677,27 @@ export function listBenchmarkDatatables(workspace: string): DataTableTables[] | })) } +// ============= DuckLake catalogs and declared metrics ============= + +/** Seeded DuckLake names, or `null` for a non-benchmark workspace. */ +export function listBenchmarkDucklakes(workspace: string): string[] | null { + const runnables = benchmarkWorkspaceRunnables.get(workspace) + return runnables ? (runnables.ducklakes ?? []) : null +} + +/** + * Seeded metric declarations, or `null` for a non-benchmark workspace. + * + * The `table` / `path_prefix` filters are ignored: which rows a filter selects is + * `canonical_table_path`'s business and is pinned by `ducklakeTools.test.ts`. + * Re-deriving it here would give the eval its own copy of that spec to drift from, + * and the case this serves measures whether the model reaches for the tool at all. + */ +export function listBenchmarkDataMetrics(workspace: string): DataMetric[] | null { + const runnables = benchmarkWorkspaceRunnables.get(workspace) + return runnables ? (runnables.dataMetrics ?? []) : null +} + export function getBenchmarkDatatableSchema(input: { workspace: string datatableName: string @@ -809,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 e5b275b86e..c9e889b9a0 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -62,6 +62,7 @@ vi.mock('$lib/gen', async () => { getBenchmarkDatatableSchema, getBenchmarkDraftForUser, getBenchmarkFlowByPath, + getBenchmarkFlowAllResults, getBenchmarkJobLogs, getBenchmarkOwnDraft, getBenchmarkScriptByHash, @@ -75,7 +76,9 @@ vi.mock('$lib/gen', async () => { listBenchmarkPlainResources, listBenchmarkApps, listBenchmarkDatatables, + listBenchmarkDataMetrics, listBenchmarkDrafts, + listBenchmarkDucklakes, listBenchmarkFlows, listBenchmarkJobs, listBenchmarkScripts, @@ -86,6 +89,7 @@ vi.mock('$lib/gen', async () => { previewBenchmarkSchedule, runBenchmarkDatatableSql, runBenchmarkFlowByPath, + runBenchmarkFlowPreview, runBenchmarkScriptByPath, runBenchmarkScriptPreview, updateBenchmarkDraft, @@ -292,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 @@ -325,7 +337,11 @@ vi.mock('$lib/gen', async () => { getJobLogs: async (data: { workspace: string; id: string }) => hasBenchmarkWorkspace(data.workspace) ? getBenchmarkJobLogs(data.workspace, data.id) - : actual.JobService.getJobLogs(data) + : actual.JobService.getJobLogs(data), + getFlowAllResults: async (data: { workspace: string; id: string }) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkFlowAllResults(data.workspace, data.id) + : actual.JobService.getFlowAllResults(data) }), WorkspaceService: wrapService(actual.WorkspaceService, { getCopilotInfo: async (data: { workspace: string }) => @@ -336,6 +352,10 @@ vi.mock('$lib/gen', async () => { hasBenchmarkWorkspace(data.workspace) ? (listBenchmarkDatatables(data.workspace) ?? []) : actual.WorkspaceService.listDataTableTables(data), + listDucklakes: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? (listBenchmarkDucklakes(data.workspace) ?? []) + : actual.WorkspaceService.listDucklakes(data), getDataTableTableSchema: async (data: { workspace: string datatableName: string @@ -351,6 +371,12 @@ vi.mock('$lib/gen', async () => { }) : actual.WorkspaceService.getDataTableTableSchema(data) }), + DataMetricService: wrapService(actual.DataMetricService, { + listDataMetrics: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? { metrics: listBenchmarkDataMetrics(data.workspace) ?? [] } + : actual.DataMetricService.listDataMetrics(data) + }), ScheduleService: wrapService(actual.ScheduleService, { existsSchedule: async (data: { workspace: string; path: string }) => hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data), diff --git a/ai_evals/adapters/frontend/windmillBackend.ts b/ai_evals/adapters/frontend/windmillBackend.ts index 2247d8e5d5..bf483aa1f4 100644 --- a/ai_evals/adapters/frontend/windmillBackend.ts +++ b/ai_evals/adapters/frontend/windmillBackend.ts @@ -1,9 +1,8 @@ -import { randomUUID } from "node:crypto"; import type { WindmillBackendSettings } from "../../core/windmillBackendSettings"; +import { buildWorkspaceId } from "./workspaceId"; const tokenCache = new Map>(); const sharedWorkspaceQueue = new Map>(); -const DEFAULT_WORKSPACE_PREFIX = "ai-evals"; export class WindmillBackendClient { constructor(private readonly settings: WindmillBackendSettings) {} @@ -179,16 +178,6 @@ async function withSharedWorkspaceLock( } } -function buildWorkspaceId(caseId: string, attempt: number): string { - const caseSlug = caseId - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 30); - const suffix = randomUUID().slice(0, 8); - return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || "case"}-a${attempt}-${suffix}`; -} - async function expectOk(response: Response, context: string): Promise { if (response.ok) { return; diff --git a/ai_evals/adapters/frontend/workspaceId.test.ts b/ai_evals/adapters/frontend/workspaceId.test.ts new file mode 100644 index 0000000000..3573a9e51a --- /dev/null +++ b/ai_evals/adapters/frontend/workspaceId.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "bun:test"; +import { buildWorkspaceId } from "./workspaceId"; + +describe("buildWorkspaceId", () => { + // `workspace.proper_id` rejects `--`, which a case id can carry itself and + // which truncating a slug on a hyphen produces once the suffix adds its own. + // One id per shape: cut landing on a hyphen, cut landing mid-word, no cut, and + // a doubled hyphen no cut ever reaches. + it("stays within the id length cap and the proper_id format", () => { + for (const caseId of [ + "global-test6-secret-variable-draft", + "global-test23-datatable-query-select", + "short", + "global--test-foo", + ]) { + const id = buildWorkspaceId(caseId, 1); + expect(id.length).toBeLessThanOrEqual(50); + expect(id).toMatch(/^\w+(-\w+)*$/); + } + }); +}); diff --git a/ai_evals/adapters/frontend/workspaceId.ts b/ai_evals/adapters/frontend/workspaceId.ts new file mode 100644 index 0000000000..9545c06a7d --- /dev/null +++ b/ai_evals/adapters/frontend/workspaceId.ts @@ -0,0 +1,22 @@ +import { randomUUID } from "node:crypto"; + +const DEFAULT_WORKSPACE_PREFIX = "ai-evals"; + +// A workspace id must be at most 50 characters AND match `^\w+(-\w+)*$` +// (`workspace.proper_id`), so the case slug yields to the random suffix that +// makes the id unique, and no hyphen may end up doubled — neither one already in +// the case id nor one a truncation leaves for the suffix to follow. +const MAX_WORKSPACE_ID_LENGTH = 50; + +export function buildWorkspaceId(caseId: string, attempt: number): string { + const caseSlug = caseId + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, ""); + const suffix = `-a${attempt}-${randomUUID().slice(0, 8)}`; + const head = `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || "case"}`; + return `${head + .slice(0, MAX_WORKSPACE_ID_LENGTH - suffix.length) + .replace(/-+$/, "")}${suffix}`; +} diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 71693156a9..45316b2810 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -889,13 +889,13 @@ draftCountExactly: 0 toolExpect: requiredToolsUsed: - - get_job_logs + - get_run forbiddenToolsUsed: - deploy_workspace_item - delete_workspace_item - write_script toolCallArgs: - - tool: get_job_logs + - tool: get_run field: id stringIncludesAnyOf: - 01920000-0000-7000-8000-0000000000f1 @@ -906,6 +906,34 @@ - fetches the logs for the requested job id - explains the failure from the returned logs (connection refused to the upstream API) +- id: global-run-args-and-result + prompt: |- + What was the run 01920000-0000-7000-8000-0000000000f2 called with, and what did it return? + initial: ai_evals/fixtures/frontend/global/initial/jobs_seed.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - get_run + forbiddenToolsUsed: + - test_run_script + - run_script + - deploy_workspace_item + toolCallArgs: + - tool: get_run + field: id + stringIncludesAnyOf: + - 01920000-0000-7000-8000-0000000000f2 + # Read-only, so no draft for the global judge to score — validated on tool use + # and the deterministic argument check, like the neighbouring run cases. + skipJudge: true + judgeChecklist: + - reports the arguments the run was called with (region emea, 12 recipients) + - reports what the run returned (12 sent, 3 skipped) + - does not start a new run to find out + # --- Page navigation (open_page) --- # The assistant should take the user to a Windmill page (Runs/Schedules) with the # right filters via open_page, rather than describing where to click or dumping the @@ -1891,10 +1919,11 @@ - when the lookup fails, tells the user instead of inventing table names - does not write scripts or resources to answer a read-only question -# --- API catalog (search_api_endpoints / call_api_get / call_api_endpoint) --- -# The harness serves the catalog and the executed calls itself (mock -# listMcpTools + benchmark fetch handlers in adapters/frontend), so these cases -# do not require an mcp-enabled eval backend. +# --- Dedicated tools preferred over the API catalog --- +# The harness serves worker/queue reads itself (benchmark fetch handlers in +# adapters/frontend), so these cases do not require an mcp-enabled eval backend. +# The stale `api-catalog` in the id below is kept so results stay comparable +# across benchmark runs. - id: global-test30-api-catalog-workers prompt: |- @@ -1906,23 +1935,42 @@ draftCountExactly: 0 toolExpect: requiredToolsUsed: - - search_api_endpoints - - call_api_get + - list_workers forbiddenToolsUsed: + - call_api_get - call_api_endpoint - write_script - deploy_workspace_item - toolCallArgs: - - tool: call_api_get - field: name - stringIncludesAnyOf: - - listWorkers # Read-only workspace inspection produces no draft; validate via tool use. skipJudge: true judgeChecklist: - - discovers the workers endpoint through the API catalog instead of guessing or fabricating + - reads worker state through list_workers instead of guessing or fabricating - reports worker status from the returned data +- id: global-test37-ducklake-declared-measure + prompt: |- + We track orders in the main ducklake. Write me a duckdb script that reports total + revenue by month. Keep it as a draft, don't deploy it. + initial: ai_evals/fixtures/frontend/global/initial/ducklake_orders_metrics.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + toolExpect: + requiredToolsUsed: + - list_data_metrics + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + # The judge runs: the point is not that the tool was called but that the number it + # describes is the declared one. `revenue` excludes test rows, so an aggregate that + # reproduces it without the filter is plausible, runnable and wrong. + judgeChecklist: + - totals revenue with the declared sum over the amount column rather than an invented aggregate over a guessed column + - excludes test orders from the total, as the declared revenue measure does + - groups by month using the declared order_month expression over order_date + - does not introduce column names absent from the declarations + - id: global-test31-draft-test-run-not-deployed prompt: |- Update `f/evals/global/format_greeting` so the provided name is uppercased in the greeting, then run it with name "ada" to check it works. @@ -2112,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/ducklake_orders_metrics.json b/ai_evals/fixtures/frontend/global/initial/ducklake_orders_metrics.json new file mode 100644 index 0000000000..fba88c53d3 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/ducklake_orders_metrics.json @@ -0,0 +1,36 @@ +{ + "workspace": { + "ducklakes": ["main"], + "dataMetrics": [ + { + "script_path": "f/analytics/orders_pipeline", + "table_path": "main/main.orders", + "kind": "measure", + "name": "revenue", + "expr": "sum(amount)", + "filter": "not is_test" + }, + { + "script_path": "f/analytics/orders_pipeline", + "table_path": "main/main.orders", + "kind": "measure", + "name": "order_count", + "expr": "count(*)" + }, + { + "script_path": "f/analytics/orders_pipeline", + "table_path": "main/main.orders", + "kind": "dimension", + "name": "order_month", + "expr": "date_trunc('month', order_date)" + }, + { + "script_path": "f/analytics/orders_pipeline", + "table_path": "main/main.orders", + "kind": "dimension", + "name": "region", + "expr": "region" + } + ] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/jobs_seed.json b/ai_evals/fixtures/frontend/global/initial/jobs_seed.json index b075d6df26..4c0f923553 100644 --- a/ai_evals/fixtures/frontend/global/initial/jobs_seed.json +++ b/ai_evals/fixtures/frontend/global/initial/jobs_seed.json @@ -15,6 +15,8 @@ "jobKind": "script", "createdBy": "bob", "success": true, + "args": { "region": "emea", "dry_run": false, "recipients": 12 }, + "result": { "sent": 12, "skipped": 3, "digest_url": "https://reports.example.com/d/2026-06-09" }, "logs": "Generating daily digest...\nDigest emailed to 12 recipients\nDone in 1.2s" }, { 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/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-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-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-c383cc023714b361d10c10e8fef1fc148ab1da942951ee9ffdddaecee76a6be9.json b/backend/.sqlx/query-48c8522a4fed219c5011f4ba63c81cfe028a8b2a32bd790840cef65c452a8c31.json similarity index 77% rename from backend/.sqlx/query-c383cc023714b361d10c10e8fef1fc148ab1da942951ee9ffdddaecee76a6be9.json rename to backend/.sqlx/query-48c8522a4fed219c5011f4ba63c81cfe028a8b2a32bd790840cef65c452a8c31.json index 56a3642faa..1b50ef4134 100644 --- a/backend/.sqlx/query-c383cc023714b361d10c10e8fef1fc148ab1da942951ee9ffdddaecee76a6be9.json +++ b/backend/.sqlx/query-48c8522a4fed219c5011f4ba63c81cfe028a8b2a32bd790840cef65c452a8c31.json @@ -1,6 +1,6 @@ { "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", + "query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2", "describe": { "columns": [ { @@ -37,6 +37,11 @@ "ordinal": 6, "name": "created_by", "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "is_test", + "type_info": "Bool" } ], "parameters": { @@ -52,8 +57,9 @@ true, false, false, + false, false ] }, - "hash": "c383cc023714b361d10c10e8fef1fc148ab1da942951ee9ffdddaecee76a6be9" + "hash": "48c8522a4fed219c5011f4ba63c81cfe028a8b2a32bd790840cef65c452a8c31" } 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-5b9c9eb64051f291fed4be9bc0b0cc0aef2e7bde732899976eddac36a2da7658.json b/backend/.sqlx/query-5b9c9eb64051f291fed4be9bc0b0cc0aef2e7bde732899976eddac36a2da7658.json new file mode 100644 index 0000000000..8864b75b79 --- /dev/null +++ b/backend/.sqlx/query-5b9c9eb64051f291fed4be9bc0b0cc0aef2e7bde732899976eddac36a2da7658.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE flow_conversation SET title = $1, updated_at = updated_at\n WHERE id = $2 AND workspace_id = $3\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5b9c9eb64051f291fed4be9bc0b0cc0aef2e7bde732899976eddac36a2da7658" +} 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-6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe.json b/backend/.sqlx/query-6d259b8cce5da5fecefe4ce322789b6b2cc43b51f2056c677d58f39c31fb26cb.json similarity index 71% rename from backend/.sqlx/query-6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe.json rename to backend/.sqlx/query-6d259b8cce5da5fecefe4ce322789b6b2cc43b51f2056c677d58f39c31fb26cb.json index d7f5fc45d4..b666243d40 100644 --- a/backend/.sqlx/query-6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe.json +++ b/backend/.sqlx/query-6d259b8cce5da5fecefe4ce322789b6b2cc43b51f2056c677d58f39c31fb26cb.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, is_test)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (id) DO NOTHING\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test", "describe": { "columns": [ { @@ -37,6 +37,11 @@ "ordinal": 6, "name": "created_by", "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "is_test", + "type_info": "Bool" } ], "parameters": { @@ -45,7 +50,8 @@ "Varchar", "Varchar", "Varchar", - "Varchar" + "Varchar", + "Bool" ] }, "nullable": [ @@ -55,8 +61,9 @@ true, false, false, + false, false ] }, - "hash": "6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe" + "hash": "6d259b8cce5da5fecefe4ce322789b6b2cc43b51f2056c677d58f39c31fb26cb" } 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-dd89d652154748d6d7e625e31778f6885d0ee62d29a4b8894a4b459dd215a103.json b/backend/.sqlx/query-9008f9abb70a9a07e38acb20bea6a710d0efd77dac4aedeb88d72240e816530b.json similarity index 65% rename from backend/.sqlx/query-dd89d652154748d6d7e625e31778f6885d0ee62d29a4b8894a4b459dd215a103.json rename to backend/.sqlx/query-9008f9abb70a9a07e38acb20bea6a710d0efd77dac4aedeb88d72240e816530b.json index 4a82795a3c..758da55ad2 100644 --- a/backend/.sqlx/query-dd89d652154748d6d7e625e31778f6885d0ee62d29a4b8894a4b459dd215a103.json +++ b/backend/.sqlx/query-9008f9abb70a9a07e38acb20bea6a710d0efd77dac4aedeb88d72240e816530b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n j.args as \"args: Json>>\",\n js.flow_status as \"flow_status: Json\"\n FROM v2_job_status js\n INNER JOIN v2_job j ON j.id = js.id\n WHERE js.id = $1\n ", + "query": "\n SELECT\n j.args as \"args: Json>>\",\n js.flow_status as \"flow_status: Json\",\n j.runnable_path\n FROM v2_job_status js\n INNER JOIN v2_job j ON j.id = js.id\n WHERE js.id = $1\n ", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "flow_status: Json", "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "runnable_path", + "type_info": "Varchar" } ], "parameters": { @@ -20,9 +25,10 @@ ] }, "nullable": [ + true, true, true ] }, - "hash": "dd89d652154748d6d7e625e31778f6885d0ee62d29a4b8894a4b459dd215a103" + "hash": "9008f9abb70a9a07e38acb20bea6a710d0efd77dac4aedeb88d72240e816530b" } 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-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-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-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/.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/.sqlx/query-dd84f9dfb238d18cb74f9e43228345427131bf8008eba6920021d6a449791534.json b/backend/.sqlx/query-dd84f9dfb238d18cb74f9e43228345427131bf8008eba6920021d6a449791534.json new file mode 100644 index 0000000000..a73c736a1d --- /dev/null +++ b/backend/.sqlx/query-dd84f9dfb238d18cb74f9e43228345427131bf8008eba6920021d6a449791534.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test\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" + }, + { + "ordinal": 7, + "name": "is_test", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "dd84f9dfb238d18cb74f9e43228345427131bf8008eba6920021d6a449791534" +} 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/Cargo.lock b/backend/Cargo.lock index 8ce8430ab3..5f5d5f2151 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -549,7 +549,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -561,7 +561,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -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]] @@ -10572,15 +10571,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873b730df6f0a9b74b13eb514e0dca4c2db0d8b68b74af98a2e9bf3f9d436585" +checksum = "cd740c45d66ceb87e5579082abc27bd771665e464e9660a17a048c721b2a6025" dependencies = [ "darling 0.24.1", "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", @@ -12737,6 +12736,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -13025,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", ] @@ -13044,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", ] @@ -13104,7 +13114,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -14064,7 +14074,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -14168,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" @@ -14535,7 +14545,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "wasm-bindgen-shared", ] @@ -14578,7 +14588,7 @@ checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -14783,7 +14793,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-nats", @@ -14871,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.812.0" +version = "1.814.0" dependencies = [ "async-stream", "async-trait", @@ -14885,6 +14895,7 @@ dependencies = [ "eventsource-stream", "futures", "http 1.5.0", + "indexmap 2.14.2", "lazy_static", "mime_guess", "reqwest 0.13.5", @@ -14904,7 +14915,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14917,7 +14928,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "argon2", @@ -15057,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15080,7 +15091,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15097,7 +15108,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15123,7 +15134,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.812.0" +version = "1.814.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15133,7 +15144,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15150,7 +15161,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15172,7 +15183,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15195,7 +15206,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15211,7 +15222,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15233,7 +15244,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15255,7 +15266,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15269,7 +15280,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-nats", @@ -15304,7 +15315,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15329,7 +15340,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15357,7 +15368,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15379,7 +15390,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15399,7 +15410,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15437,7 +15448,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15465,7 +15476,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.812.0" +version = "1.814.0" dependencies = [ "lazy_static", "serde", @@ -15477,7 +15488,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.812.0" +version = "1.814.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15501,7 +15512,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15515,7 +15526,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.812.0" +version = "1.814.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15550,7 +15561,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.812.0" +version = "1.814.0" dependencies = [ "chrono", "lazy_static", @@ -15564,7 +15575,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15583,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.812.0" +version = "1.814.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15654,6 +15665,7 @@ dependencies = [ "serde", "serde_json", "serde_yml", + "sha1", "sha2 0.10.9", "size", "spki", @@ -15689,7 +15701,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.812.0" +version = "1.814.0" dependencies = [ "chrono", "futures", @@ -15709,7 +15721,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.812.0" +version = "1.814.0" dependencies = [ "regex", "serde", @@ -15726,7 +15738,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15753,7 +15765,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "futures", @@ -15770,7 +15782,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.812.0" +version = "1.814.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15786,7 +15798,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -15807,7 +15819,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -15838,7 +15850,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "arc-swap", @@ -15863,7 +15875,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-stream", @@ -15898,7 +15910,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "futures", @@ -15916,7 +15928,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.812.0" +version = "1.814.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15925,7 +15937,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -15937,7 +15949,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -15949,7 +15961,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "gosyn", @@ -15961,7 +15973,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -15973,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -15985,7 +15997,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "nu-parser", @@ -15996,7 +16008,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16007,7 +16019,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16019,7 +16031,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16030,7 +16042,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-recursion", @@ -16052,7 +16064,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -16064,7 +16076,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -16078,7 +16090,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16095,7 +16107,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -16108,7 +16120,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde", @@ -16120,7 +16132,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -16138,7 +16150,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16154,7 +16166,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16170,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -16184,7 +16196,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-recursion", @@ -16223,7 +16235,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "const_format", @@ -16263,7 +16275,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.812.0" +version = "1.814.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16274,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-recursion", @@ -16309,7 +16321,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16333,7 +16345,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16366,7 +16378,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16393,7 +16405,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16426,7 +16438,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16446,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16480,7 +16492,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16516,7 +16528,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16539,7 +16551,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16563,7 +16575,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-nats", @@ -16587,7 +16599,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16622,7 +16634,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16650,7 +16662,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-trait", @@ -16675,7 +16687,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16694,7 +16706,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-once-cell", @@ -16812,7 +16824,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.812.0" +version = "1.814.0" dependencies = [ "bytes", "futures", @@ -17445,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]] @@ -17500,14 +17512,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -17541,14 +17553,14 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -17603,7 +17615,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -17620,9 +17632,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 61cc8bd1c1..395e49afdc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.812.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.812.0" +version = "1.814.0" authors = ["Ruben Fiszel "] edition = "2021" 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/20260916155738_flow_conversation_is_test.down.sql b/backend/migrations/20260916155738_flow_conversation_is_test.down.sql new file mode 100644 index 0000000000..aa186105cf --- /dev/null +++ b/backend/migrations/20260916155738_flow_conversation_is_test.down.sql @@ -0,0 +1 @@ +ALTER TABLE flow_conversation DROP COLUMN is_test; diff --git a/backend/migrations/20260916155738_flow_conversation_is_test.up.sql b/backend/migrations/20260916155738_flow_conversation_is_test.up.sql new file mode 100644 index 0000000000..970375a84d --- /dev/null +++ b/backend/migrations/20260916155738_flow_conversation_is_test.up.sql @@ -0,0 +1,26 @@ +-- A chat run from the flow editor's test panel is stored exactly like one from the +-- deployed flow, so the two were indistinguishable once written. Marking them lets the +-- lists tell a trial apart from a real conversation. +ALTER TABLE flow_conversation ADD COLUMN is_test BOOLEAN NOT NULL DEFAULT false; + +-- Existing rows: a conversation whose messages came from a flowpreview run was a test. +-- Derived once here because the job is purged on retention, after which the origin of an +-- old conversation is unknowable. +-- +-- Walked to the root job rather than matched directly: an existing message row never holds +-- the flow job itself. The rows point at the step that produced them — the AI agent's job +-- for an answer, the tool's own job for a tool call — whose kind is never 'flowpreview'. +-- +-- `root_job` first, matching `get_root_job_id` (windmill-worker/src/common.rs): only it +-- reaches the top of the run. `flow_innermost_root_job` stops at the closest flow scope by +-- design, so an agent inside a subflow would land on that subflow's 'flow' row and the +-- conversation would read as deployed. +UPDATE flow_conversation c +SET is_test = true +WHERE EXISTS ( + SELECT 1 FROM flow_conversation_message m + JOIN v2_job j ON j.id = m.job_id + JOIN v2_job root + ON root.id = coalesce(j.root_job, j.flow_innermost_root_job, j.parent_job, j.id) + WHERE m.conversation_id = c.id AND root.kind = 'flowpreview' +); 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/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index f760f0aaf7..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.812.0" +version = "1.814.0" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.812.0" +version = "1.814.0" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.812.0" +version = "1.814.0" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.812.0" +version = "1.814.0" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.812.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 8ea14f7df4..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.812.0" +version = "1.814.0" edition = "2021" authors = ["Ruben Fiszel "] 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/summarized_schema.txt b/backend/summarized_schema.txt index 1e27ee1ce4..e27cb2893c 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -99,9 +99,9 @@ email_trigger: path(char), local_part(char), workspaced_local_part(bool), script favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind) flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[]) 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) +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/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/tests/v2_job_delete_orphans.rs b/backend/tests/v2_job_delete_orphans.rs index 95cd1673b6..2b11deab77 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,186 @@ 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, + false, + ) + .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, + None, + ) + .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 +372,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-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-ai/src/types.rs b/backend/windmill-ai/src/types.rs index ae85718e8a..12e06a35d8 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -78,17 +78,50 @@ impl Default for OutputType { #[serde(tag = "kind", rename_all = "lowercase")] pub enum Memory { Off, - Auto { - #[serde(default)] + Window { + #[serde(default, deserialize_with = "deserialize_null_as_zero")] context_length: usize, - #[serde(default)] + }, + /// Written before `window`. Its `memory_id` stays a fallback behind the run's memory id. + Auto { + #[serde(default, deserialize_with = "deserialize_null_as_zero")] + context_length: usize, + #[serde(default, deserialize_with = "deserialize_blank_as_none")] memory_id: Option, }, + /// Written before a step had history inputs of its own, and read on its own where it remains. Manual { messages: Vec, }, } +// An editor form can leave `""` in a legacy baked id it never filled; it means no id rather than +// failing every run of the step. +fn deserialize_blank_as_none<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + match as serde::Deserialize>::deserialize(deserializer)? { + Some(id) if !id.trim().is_empty() => Uuid::parse_str(id.trim()) + .map(Some) + .map_err(serde::de::Error::custom), + _ => Ok(None), + } +} + +// A count the editor's number field was cleared of is stored as `null`, which `default` does not +// cover; it reads as 0, memory off, rather than failing every run of the step. +fn deserialize_null_as_zero<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result { + as serde::Deserialize>::deserialize(deserializer).map(Option::unwrap_or_default) +} + +fn deserialize_present<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + ::deserialize(deserializer).map(Some) +} + #[derive(Deserialize)] struct AIAgentArgsRaw { provider: ProviderWithResource, @@ -103,6 +136,12 @@ struct AIAgentArgsRaw { streaming: Option, max_iterations: Option, memory: Option, + // A null must stay distinguishable from an absent key: a step whose own memory id evaluates to + // nothing runs stateless instead of falling back to the run's memory id. + #[serde(default, deserialize_with = "deserialize_present")] + memory_id: Option, + #[serde(default)] + previous_messages: Option>, enabled_tools: Option>, // Legacy field for backward compatibility messages_context_length: Option, @@ -124,6 +163,10 @@ pub struct AIAgentArgs { pub streaming: Option, pub max_iterations: Option, pub memory: Option, + /// Memory id set on the step, overriding the run's. Empty when its expression produced none. + pub memory_id: Option, + /// History supplied by the flow, replayed without reading or writing memory. + pub previous_messages: Option>, /// Which of the agent's tools this run may call; `narrow_roster` holds what the names are and /// what `None` means. pub enabled_tools: Option>, @@ -139,12 +182,17 @@ impl From for AIAgentArgs { }); // Backward compatibility: if context_length is 0, use off mode - let memory = memory.map(|memory| { - if let Memory::Auto { context_length: 0, .. } = memory { + let memory = memory.map(|memory| match memory { + Memory::Auto { context_length: 0, .. } | Memory::Window { context_length: 0 } => { Memory::Off - } else { - memory } + memory => memory, + }); + + let memory_id = raw.memory_id.map(|value| match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.trim().to_string(), + value => value.to_string(), }); AIAgentArgs { @@ -159,6 +207,8 @@ impl From for AIAgentArgs { streaming: raw.streaming, max_iterations: raw.max_iterations, memory, + memory_id, + previous_messages: raw.previous_messages, enabled_tools: raw.enabled_tools, credentials_check: raw.credentials_check.unwrap_or(false), } 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-flow-conversations/src/lib.rs b/backend/windmill-api-flow-conversations/src/lib.rs index e85af5b83b..0ec295c23f 100644 --- a/backend/windmill-api-flow-conversations/src/lib.rs +++ b/backend/windmill-api-flow-conversations/src/lib.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Path, Query}, - routing::{delete, get}, + routing::{delete, get, post}, Extension, Json, Router, }; use chrono::{DateTime, Utc}; @@ -15,13 +15,14 @@ use windmill_common::{ db::{UserDB, DB}, error::{JsonResult, Result}, flow_conversations::MessageType, - utils::{not_found_if_none, paginate, Pagination}, + utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination}, }; pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_conversations)) .route("/delete/{conversation_id}", delete(delete_conversation)) + .route("/update/{conversation_id}", post(update_conversation)) .route("/{conversation_id}/messages", get(list_messages)) } @@ -36,11 +37,37 @@ 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 +/// panel; a deployed one from the flow itself. +#[derive(Deserialize, Default, Clone, Copy)] +#[serde(rename_all = "lowercase")] +pub enum ConversationKind { + Test, + /// The default: a deployed flow's chat should not surface someone's trial runs. + #[default] + Deployed, + All, } #[derive(Deserialize)] pub struct ListConversationsQuery { pub flow_path: Option, + pub kind: Option, } #[derive(Deserialize)] @@ -67,6 +94,7 @@ async fn list_conversations( "created_at", "updated_at", "created_by", + "is_test", ]) .and_where_eq("workspace_id", "?".bind(&w_id)); @@ -74,6 +102,16 @@ async fn list_conversations( sqlb.and_where_eq("flow_path", "?".bind(flow_path)); } + match query.kind.unwrap_or_default() { + ConversationKind::Test => { + sqlb.and_where_eq("is_test", "true"); + } + ConversationKind::Deployed => { + sqlb.and_where_eq("is_test", "false"); + } + ConversationKind::All => {} + } + sqlb.order_by("updated_at", true) .limit(per_page as i64) .offset(offset as i64); @@ -101,7 +139,7 @@ async fn delete_conversation( // Verify the conversation exists and belongs to the user let conversation = sqlx::query_as!( FlowConversation, - "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by + "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test FROM flow_conversation WHERE id = $1 AND workspace_id = $2", conversation_id, @@ -148,6 +186,50 @@ async fn delete_conversation( Ok(format!("Conversation {} deleted", conversation_id)) } +#[derive(Deserialize)] +pub struct UpdateConversation { + pub title: String, +} + +async fn update_conversation( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, conversation_id)): Path<(String, Uuid)>, + Json(update): Json, +) -> Result { + // Postgres refuses a NUL in a text column, so it must not reach the query as a 500. + if update.title.contains('\0') { + return Err(windmill_common::error::Error::BadRequest( + "title cannot contain a NUL character".to_string(), + )); + } + // The column is VARCHAR(255) and the helper appends an ellipsis to what it cuts, so the + // bound it takes is three short of the column's. A longer title would otherwise reach + // Postgres as a 22001 and come back a 500. + let title = truncate_with_ellipsis(update.title.trim(), 252); + + let mut tx = user_db.clone().begin(&authed).await?; + + // `updated_at` is kept: the list is ordered by it, and a rename must not move the + // chat to the top the way a new turn does. + let updated = sqlx::query_scalar!( + "UPDATE flow_conversation SET title = $1, updated_at = updated_at + WHERE id = $2 AND workspace_id = $3 + RETURNING id", + title, + conversation_id, + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + + not_found_if_none(updated, "Conversation", conversation_id.to_string())?; + + tx.commit().await?; + + Ok(format!("Conversation {} updated", conversation_id)) +} + async fn list_messages( authed: ApiAuthed, Extension(user_db): Extension, @@ -178,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 @@ -195,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-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 525e3cca46..5ac0e0588c 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -154,6 +154,7 @@ async fn list_flows( "favorite.path IS NOT NULL as starred", "ws_error_handler_muted", "o.labels", + "(o.value->>'chat_input_enabled')::bool as chat_input_enabled", "draft.email IS NOT NULL as is_draft", // Per-path draft owners as a JSON array; see scripts.rs for the rationale // (non-member superadmin identity fallback via `password`, legacy NULL-email row). @@ -301,6 +302,10 @@ async fn list_flows( ws_error_handler_muted: None, deployment_msg: None, labels: None, + chat_input_enabled: v + .get("value") + .and_then(|fv| fv.get("chat_input_enabled")) + .and_then(|b| b.as_bool()), // No deployed row to inherit folder labels from. inherited_labels: None, is_draft: true, 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-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 310e44ae1f..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, @@ -653,9 +655,11 @@ pub async fn set_flow_memory_id( pub async fn process_flow_run_query_params( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, job_id: Uuid, + w_id: &str, + flow_path: &str, run_query: &RunJobQuery, ) -> error::Result<()> { - if let Some(memory_id) = run_query.memory_id { + if let Some(memory_id) = run_query.memory_key(w_id, flow_path) { set_flow_memory_id(tx, job_id, memory_id).await?; } Ok(()) @@ -668,10 +672,19 @@ pub async fn handle_chat_conversation_messages( flow_path: &str, run_query: &RunJobQuery, 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<()> { - let memory_id = run_query.memory_id.ok_or_else(|| { + // 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_key(w_id, flow_path).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(), ) })?; @@ -695,17 +708,22 @@ pub async fn handle_chat_conversation_messages( &authed.username, &user_message, memory_id, + is_test, ) .await?; + // 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, memory_id, - None, + Some(job_id), &user_message, MessageType::User, None, true, + Some(&MessageExtras { attachments: message_attachments(args), ..Default::default() }), ) .await?; @@ -813,7 +831,7 @@ pub async fn run_flow<'c>( .await?; // Set memory_id if provided (for agent memory) - if let Some(memory_id) = run_query.memory_id { + if let Some(memory_id) = run_query.memory_key(w_id, flow_path) { set_flow_memory_id(&mut tx, uuid, memory_id).await?; } @@ -826,6 +844,9 @@ pub async fn run_flow<'c>( &flow_path.to_string(), &run_query, args.args.get("user_message"), + uuid, + false, + &args.args, ) .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-jobs/src/types.rs b/backend/windmill-api-jobs/src/types.rs index 6c2d776e30..f37040bd11 100644 --- a/backend/windmill-api-jobs/src/types.rs +++ b/backend/windmill-api-jobs/src/types.rs @@ -47,13 +47,25 @@ pub struct RunJobQuery { pub cache_ignore_s3_path: Option, pub skip_preprocessor: Option, pub poll_delay_ms: Option, - pub memory_id: Option, + /// Any string; see [`RunJobQuery::memory_key`]. + pub memory_id: Option, pub trigger_external_id: Option, pub service_name: Option, pub suspended_mode: Option, } impl RunJobQuery { + /// The memory id as stored in `flow_status.memory_id`: a uuid is kept, any other string hashed + /// within the workspace and the flow being run. + pub fn memory_key(&self, workspace_id: &str, flow_path: &str) -> Option { + self.memory_id + .as_deref() + .filter(|memory_id| !memory_id.trim().is_empty()) + .map(|memory_id| { + windmill_common::flow_conversations::memory_key(workspace_id, flow_path, memory_id) + }) + } + pub async fn get_scheduled_for( &self, db: &DB, 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-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-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8f188bf763..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.812.0 + version: 1.814.0 title: Windmill API contact: @@ -11267,11 +11267,10 @@ paths: - $ref: "#/components/parameters/NewJobId" - $ref: "#/components/parameters/SkipPreprocessor" - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid requestBody: description: script args @@ -11308,11 +11307,10 @@ paths: - $ref: "#/components/parameters/NewJobId" - $ref: "#/components/parameters/SkipPreprocessor" - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid requestBody: description: script args @@ -11349,11 +11347,10 @@ paths: - $ref: "#/components/parameters/NewJobId" - $ref: "#/components/parameters/SkipPreprocessor" - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid responses: "200": @@ -11376,11 +11373,10 @@ paths: - $ref: "#/components/parameters/NewJobId" - $ref: "#/components/parameters/SkipPreprocessor" - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -11418,11 +11414,10 @@ paths: - $ref: "#/components/parameters/NewJobId" - $ref: "#/components/parameters/SkipPreprocessor" - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -11458,11 +11453,10 @@ paths: - $ref: "#/components/parameters/NewJobId" - $ref: "#/components/parameters/SkipPreprocessor" - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -11506,11 +11500,10 @@ paths: - $ref: "#/components/parameters/NewJobId" - $ref: "#/components/parameters/SkipPreprocessor" - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -12010,6 +12003,13 @@ paths: properties: draft_only: type: boolean + chat_input_enabled: + type: boolean + description: | + `chat_input_enabled` of the flow's value, + projected so the list can mark flows that open + as a chat. Omitted when the value has no such + field. is_draft: type: boolean description: | @@ -12457,6 +12457,15 @@ paths: in: query schema: type: string + - name: kind + description: which conversations to list - the flow editor's test chats, the deployed flow's own (the default), or both + in: query + schema: + type: string + enum: + - test + - deployed + - all responses: "200": description: flow conversations list @@ -12467,6 +12476,40 @@ paths: items: $ref: "#/components/schemas/FlowConversation" + /w/{workspace}/flow_conversations/update/{conversation_id}: + post: + summary: rename flow conversation + operationId: updateFlowConversation + tags: + - flow_conversations + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: conversation_id + description: conversation id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [title] + properties: + title: + type: string + description: the chat's name + responses: + "200": + description: flow conversation updated + content: + text/plain: + schema: + type: string + /w/{workspace}/flow_conversations/delete/{conversation_id}: delete: summary: delete flow conversation @@ -14861,11 +14904,10 @@ paths: schema: type: boolean - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid requestBody: description: flow args required: true @@ -14919,11 +14961,10 @@ paths: schema: type: boolean - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid requestBody: description: flow args required: true @@ -15411,11 +15452,10 @@ paths: type: boolean - $ref: "#/components/parameters/NewJobId" - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid requestBody: description: preview @@ -15443,11 +15483,10 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - name: memory_id - description: memory ID for chat-enabled flows + description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow. in: query schema: type: string - format: uuid requestBody: description: preview @@ -28294,7 +28333,7 @@ components: FlowConversation: type: object required: - [id, workspace_id, flow_path, created_at, updated_at, created_by] + [id, workspace_id, flow_path, created_at, updated_at, created_by, is_test] properties: id: type: string @@ -28321,6 +28360,9 @@ components: created_by: type: string description: Username who created the conversation + is_test: + type: boolean + description: Started from the flow editor's test panel rather than a deployed run FlowConversationMessage: type: object @@ -28360,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 @@ -28951,6 +29037,12 @@ components: type: boolean has_deploy_errors: type: boolean + chat_input_enabled: + type: boolean + description: >- + flow-only. `chat_input_enabled` of the flow's value, projected so + the list can mark flows that open as a chat. Omitted when the + value has no such field. raw_app: type: boolean execution_mode: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 6af6bbc37c..918fce53bf 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), @@ -4304,11 +4310,11 @@ async fn execute_component( } } - let is_flow = payload + let flow_path = payload .path - .as_ref() - .map(|p| p.starts_with("flow/")) - .unwrap_or(false); + .as_deref() + .and_then(|path| path.strip_prefix("flow/")) + .map(str::to_string); // Tag for inline-script jobs is read from the deployed policy in run mode; // only preview mode (editor) honors the client-supplied tag. This applies to @@ -4438,8 +4444,9 @@ async fn execute_component( // Apply runnable query parameters if provided if let Some(ref run_query) = payload.run_query_params { - if is_flow { - crate::jobs::process_flow_run_query_params(&mut tx, uuid, run_query).await?; + if let Some(flow_path) = flow_path.as_deref() { + crate::jobs::process_flow_run_query_params(&mut tx, uuid, &w_id, flow_path, run_query) + .await?; } } diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 84968ebbcc..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?; @@ -9540,7 +9562,7 @@ async fn run_preview_flow_job( .await?; // Set memory_id if provided (for agent memory) - if let Some(memory_id) = run_query.memory_id { + if let Some(memory_id) = run_query.memory_key(&w_id, &flow_path) { set_flow_memory_id(&mut tx, uuid, memory_id).await?; } @@ -9553,6 +9575,10 @@ async fn run_preview_flow_job( &flow_path, &run_query, user_message.as_ref(), + uuid, + // Run from the editor's test panel: a trial, not a real conversation. + true, + &flow_args, ) .await?; } diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index ea6d0c5bba..43ef23aff2 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -116,6 +116,10 @@ struct RunnableItem { use_codebase: Option, #[serde(skip_serializing_if = "Option::is_none")] has_deploy_errors: Option, + // flow-only: projected from the value so the home list can badge flows that + // open as a chat without fetching every flow's value. + #[serde(skip_serializing_if = "Option::is_none")] + chat_input_enabled: Option, // app-only #[serde(skip_serializing_if = "Option::is_none")] raw_app: Option, @@ -274,7 +278,7 @@ fn branch_sqls() -> Branches { o.ws_error_handler_muted, o.created_at as edited_at, \ o.hash, o.language::text as language, o.kind::text as script_kind, o.auto_kind, \ o.codebase IS NOT NULL as use_codebase, \ - (o.lock_error_logs IS NOT NULL) as has_deploy_errors, \ + (o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \ NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \ FROM script o \ @@ -291,6 +295,7 @@ fn branch_sqls() -> Branches { o.ws_error_handler_muted, o.edited_at, \ NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \ + (o.value->>'chat_input_enabled')::bool as chat_input_enabled, \ NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ FROM flow o \ @@ -306,7 +311,7 @@ fn branch_sqls() -> Branches { {draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \ NULL::bool as ws_error_handler_muted, av.created_at as edited_at, \ NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ - NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \ + NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \ av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \ o.versions[array_upper(o.versions, 1)] as version, \ COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ @@ -344,11 +349,21 @@ fn draft_branch_sql(kind: &str) -> String { let kind_cols = match kind { "script" => { "d.value->>'language' as language, d.value->>'kind' as script_kind, \ - d.value->>'auto_kind' as auto_kind, false as raw_app" + d.value->>'auto_kind' as auto_kind, false as raw_app, \ + NULL::bool as chat_input_enabled" + } + // Type-guarded rather than a bare `::bool` cast: draft JSON is stored + // unvalidated, so a malformed value must yield NULL, not abort the list, + // and the string "true" must not count as enabled. + "flow" => { + "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ + false as raw_app, \ + CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \ + THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled" } _ => { "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ - (d.typ = 'raw_app') as raw_app" + (d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled" } }; format!( @@ -359,7 +374,7 @@ fn draft_branch_sql(kind: &str) -> String { NULL::text[] as labels, NULL::text[] as inherited_labels, \ NULL::bool as ws_error_handler_muted, o.created_at as edited_at, \ NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \ - NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \ + NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \ o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \ FROM ( \ diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index dcf3465a94..06de7cc4aa 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -33,6 +33,7 @@ path = "src/lib.rs" tar.workspace = true hmac.workspace = true sha2.workspace = true +sha1.workspace = true thiserror.workspace = true anyhow.workspace = true serde.workspace = true 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/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 21b1f56389..91bfa4a39f 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -1,12 +1,39 @@ +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; use crate::utils::truncate_with_ellipsis; +/// Changing it detaches every memory stored under a string memory id. +const MEMORY_ID_NAMESPACE: Uuid = Uuid::from_u128(0x6f1c2d4e_8a3b_5c7d_9e0f_1a2b3c4d5e6f); + +/// Memory is stored and carried in `flow_status.memory_id` as a uuid, which names the same memory +/// wherever it is passed, as a chat conversation id must. Any other string names a memory through a +/// name-based (v5) uuid scoped to its workspace and flow, so the same key in two flows or two +/// workspaces names two memories, and chat conversation ids stay unique across workspaces. +pub fn memory_key(workspace_id: &str, flow_path: &str, memory_id: &str) -> Uuid { + let memory_id = memory_id.trim(); + Uuid::parse_str(memory_id).unwrap_or_else(|_| { + use sha1::{Digest, Sha1}; + let mut hasher = Sha1::new(); + hasher.update(MEMORY_ID_NAMESPACE.as_bytes()); + for part in [workspace_id, flow_path, memory_id] { + hasher.update(part.as_bytes()); + hasher.update([0u8]); + } + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&hasher.finalize()[..16]); + uuid::Builder::from_sha1_bytes(bytes).into_uuid() + }) +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)] #[sqlx(type_name = "MESSAGE_TYPE", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] @@ -26,8 +53,12 @@ pub struct FlowConversation { pub created_at: DateTime, pub updated_at: DateTime, pub created_by: String, + /// Started from the flow editor's test panel rather than a deployed run. + pub is_test: bool, } +/// `is_test` is written on insert. An existing conversation of the other kind refuses the +/// turn, so preview and deployed runs never share one. pub async fn get_or_create_conversation_with_id( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, w_id: &str, @@ -35,42 +66,140 @@ pub async fn get_or_create_conversation_with_id( username: &str, title: &str, conversation_id: Uuid, + is_test: bool, ) -> 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 { - return Ok(existing); + if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? { + return same_kind(existing, is_test); } // 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) - RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by", + "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO NOTHING + RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test", conversation_id, w_id, flow_path, username, - title + title, + is_test ) - .fetch_one(&mut **tx) + .fetch_optional(&mut **tx) .await?; + if let Some(conversation) = created { + return Ok(conversation); + } - Ok(conversation) + // The concurrent first turn that won the insert may have been of the other kind. + let existing = lock_conversation(tx, w_id, conversation_id) + .await? + .ok_or_else(|| { + crate::error::Error::BadRequest(format!( + "conversation {conversation_id} belongs to another workspace" + )) + })?; + same_kind(existing, is_test) +} + +/// `memory_id` is the caller's to choose, so a preview run could name a deployed +/// conversation and the reverse. A conversation's kind is fixed at creation and nothing +/// would show the mixing afterwards, so the turn is refused before it starts. +fn same_kind(existing: FlowConversation, is_test: bool) -> Result { + if existing.is_test == is_test { + return Ok(existing); + } + Err(crate::error::Error::BadRequest(if existing.is_test { + "this conversation was started from the flow editor's test panel; start a new conversation to run the deployed flow".to_string() + } else { + "this conversation belongs to the deployed flow; start a new conversation to test from the flow editor".to_string() + })) +} + +/// 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, is_test + FROM flow_conversation + WHERE id = $1 AND workspace_id = $2 + FOR UPDATE", + conversation_id, + w_id + ) + .fetch_optional(&mut **tx) + .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 @@ -84,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!( @@ -104,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?; @@ -143,3 +280,67 @@ pub async fn delete_conversation_memory( Ok(()) } + +#[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] + fn memory_key_scopes_strings_but_not_uuids() { + let key = memory_key("ws", "f/support/triage", " customer-1 "); + assert_eq!(key, memory_key("ws", "f/support/triage", "customer-1")); + assert_ne!( + key, + memory_key("other_ws", "f/support/triage", "customer-1") + ); + assert_ne!(key, memory_key("ws", "f/sales/triage", "customer-1")); + let conversation = Uuid::from_u128(7).to_string(); + assert_eq!(memory_key("ws", "f/a", &conversation), Uuid::from_u128(7)); + assert_eq!( + memory_key("other_ws", "f/b", &conversation), + Uuid::from_u128(7) + ); + } +} 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?; diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 4b93bb89ff..fd0bd634ea 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -976,18 +976,33 @@ fn six_fields_hint(schedule_str: &str, version: Option<&str>, seconds_required: } impl ScheduleType { + /// `NotFound` means the expression has no run left (an expired year, an impossible + /// date), and schedule pushes disable the schedule on it. Every other error must stay + /// transient: croner fails across a DST jump longer than an hour (Antarctica/Troll) + /// and succeeds again once the jump has passed. pub fn find_next( &self, starting_from: &chrono::DateTime, - ) -> chrono::DateTime { + ) -> Result> { + let no_run_left = || { + Error::NotFound(format!( + "cron: the schedule has no run left after {}", + starting_from.format("%Y-%m-%d %H:%M:%S %Z") + )) + }; match self { ScheduleType::Croner(croner_schedule) => croner_schedule .find_next_occurrence(starting_from, false) - .expect("cron: a schedule should have a next event"), - ScheduleType::Cron(schedule) => schedule - .after(starting_from) - .next() - .expect("cron: a schedule should have a next event"), + .map_err(|e| match e { + croner::errors::CronError::TimeSearchLimitExceeded => no_run_left(), + e => Error::internal_err(format!( + "cron: could not compute the run after {}: {e}", + starting_from.format("%Y-%m-%d %H:%M:%S %Z") + )), + }), + ScheduleType::Cron(schedule) => { + schedule.after(starting_from).next().ok_or_else(no_run_left) + } } } @@ -1709,6 +1724,22 @@ mod tests { assert!(!err.contains("6 fields"), "{err}"); } + #[test] + fn find_next_reports_only_a_cron_with_no_run_left_as_not_found() { + use chrono::TimeZone; + let troll: chrono_tz::Tz = "Antarctica/Troll".parse().unwrap(); + // Troll's clocks jump from 01:00 to 03:00 on the last Sunday of March. + let before_jump = troll.with_ymd_and_hms(2027, 3, 28, 0, 30, 0).unwrap(); + + let expired = ScheduleType::from_str("0 0 9 1 1 * 2026", Some("v1"), true).unwrap(); + let err = expired.find_next(&before_jump).unwrap_err(); + assert!(matches!(err, Error::NotFound(_)), "{err}"); + + let across_jump = ScheduleType::from_str("0 30 1 * * *", Some("v2"), true).unwrap(); + let err = across_jump.find_next(&before_jump).unwrap_err(); + assert!(!matches!(err, Error::NotFound(_)), "{err}"); + } + /// A worker that restarts must land on the exact same name to reclaim its `worker_ping` /// row, while still never colliding with the other workers of its own process. The /// suffix must also stay a single `-` segment, which is what the interactive shell tag diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index fa495c9cd2..5d5cf005fc 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -166,13 +166,10 @@ pub async fn push_scheduled_job<'c>( } }; - let next = sched.find_next(&starting_from); - // println!("next event ({:?}): {}", tz, next); - // println!("next event(UTC): {}", next.with_timezone(&chrono::Utc)); + let next = sched.find_next(&starting_from)?; // Scheduled events must be stored in the database in UTC let next = next.with_timezone(&chrono::Utc); - // panic!("next: {}", next); let already_exists: bool = sqlx::query_scalar!( // Query plan: // - use of the `ix_v2_job_root_by_path` index; hence the `parent_job IS NULL` clause. diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index d58d9a5a46..333d211765 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -921,6 +921,47 @@ mod schedule_push { Ok(()) } + // ----------------------------------------------------------------------- + // try_schedule_next_job: a cron with no run left disables the schedule + // ----------------------------------------------------------------------- + + #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] + async fn test_cron_with_no_run_left_disables_schedule( + db: Pool, + ) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, email, extra_perms, ws_error_handler_muted, no_flow_overlap, permissioned_as, cron_version) + VALUES ('test-workspace', 'f/system/test_schedule', 'test-user', now(), '0 0 9 1 1 * 2020', 'UTC', true, 'f/system/test_script', false, 'test@windmill.dev', '{}', false, false, 'u/test-user', 'v1')" + ) + .execute(&db) + .await?; + + let schedule = make_schedule(|s| { + s.schedule = "0 0 9 1 1 * 2020".to_string(); + s.cron_version = Some("v1".to_string()); + }); + let job = make_completed_job(&schedule); + + let tx = db.begin().await?; + let (tx, err) = + try_schedule_next_job(&db, tx, &job, &schedule, &schedule.script_path).await; + assert!(err.is_none(), "completion must go through, got: {err:?}"); + tx.commit().await?; + + assert_eq!(count_queued_jobs(&db).await, 0); + let (enabled, error): (bool, Option) = sqlx::query_as( + "SELECT enabled, error FROM schedule WHERE workspace_id = 'test-workspace' AND path = 'f/system/test_schedule'", + ) + .fetch_one(&db) + .await?; + assert!(!enabled, "schedule with no run left must be disabled"); + assert!( + error.as_deref().is_some_and(|e| e.contains("no run left")), + "error should say why, got: {error:?}" + ); + Ok(()) + } + // ----------------------------------------------------------------------- // try_schedule_next_job: disabled schedule leaves no side effects // ----------------------------------------------------------------------- diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index cb1c53a579..a2fc7fd58d 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -98,6 +98,11 @@ pub struct ListableFlow { pub deployment_msg: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Projected from the flow value so a list can mark a flow that opens as a + /// chat without fetching every flow's value. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub chat_input_enabled: Option, /// True when the authed user has a draft for this flow (draft-only or layered /// over the deployed row). See ListableScript in scripts.rs. #[serde(default)] @@ -1095,7 +1100,8 @@ pub enum FlowModuleValue { omit_output_from_conversation: bool, /// When set, the agent brain config (provider/model/system prompt/etc.) and tools are /// resolved at runtime from this `ai_agent` resource path (hybrid linking). The module's - /// `input_transforms` then only carry the flow-local inputs (user_message/user_attachments). + /// `input_transforms` then only carry the flow-local inputs: user_message, + /// user_attachments, enabled_tools and the history inputs memory_id and previous_messages. #[serde(default, skip_serializing_if = "Option::is_none")] agent: Option, /// Binds an agent's tools to *this* flow's context, keyed by tool id then input key, without 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 51a00e65a7..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, @@ -154,6 +154,8 @@ pub async fn get_flow_job_runnable_and_raw_flow( pub struct FlowContext { pub flow_inputs: Option>>, pub flow_status: Option, + /// Path of the flow the run started from, which scopes a string memory id. + pub flow_path: Option, } /// Get flow context (chat settings + args + flow_status) from root flow's job data @@ -171,7 +173,8 @@ pub async fn get_flow_context(db: &DB, job: &MiniPulledJob) -> FlowContext { r#" SELECT j.args as "args: Json>>", - js.flow_status as "flow_status: Json" + js.flow_status as "flow_status: Json", + j.runnable_path FROM v2_job_status js INNER JOIN v2_job j ON j.id = js.id WHERE js.id = $1 @@ -184,6 +187,7 @@ pub async fn get_flow_context(db: &DB, job: &MiniPulledJob) -> FlowContext { Ok(Some(row)) => FlowContext { flow_inputs: row.args.map(|j| j.0), flow_status: row.flow_status.map(|j| j.0), + flow_path: row.runnable_path, }, Ok(None) => { tracing::warn!( @@ -209,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( @@ -219,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 156369e095..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::MessageType, + flow_conversations::{memory_key, MessageExtras, MessageType}, flow_status::AgentAction, flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue}, get_latest_hash_for_path, @@ -111,6 +111,148 @@ fn prepare_auto_memory_messages_for_persistence( non_system_messages[start_idx..].to_vec() } +/// The inputs a linked step supplies for itself; the resource holds the rest of the brain. +const FLOW_LOCAL_AGENT_KEYS: [&str; 5] = [ + "user_message", + "user_attachments", + "enabled_tools", + "memory_id", + "previous_messages", +]; + +/// The flow-local inputs that name a conversation, which a saved agent never carries. +const STEP_HISTORY_KEYS: [&str; 2] = ["memory_id", "previous_messages"]; + +/// Where one agent invocation's history comes from. +#[derive(Debug)] +enum HistorySource<'a> { + /// Supplied by the flow and replayed as is: memory is neither read nor written. + Messages(&'a [OpenAIMessage]), + Window { + memory_id: Uuid, + context_length: usize, + }, + Stateless, +} + +/// A step's memory id counts only as the step authored it. A static empty value is a form +/// placeholder, so it reads as unset rather than as an expression that evaluated to nothing, which +/// runs without memory; an AI-filled value would let the model choose which memory the agent reads. +fn keep_authored_memory_id( + args: &mut AIAgentArgs, + step_input_transforms: &HashMap, +) { + match step_input_transforms.get("memory_id") { + Some(InputTransform::Javascript { .. }) => {} + Some(InputTransform::Static { .. }) if args.memory_id.as_deref() != Some("") => {} + _ => args.memory_id = None, + } +} + +/// Reconciles the step's history inputs, the agent's memory policy and the run's memory id. A step +/// holds one of two shapes: an older `auto` or `manual` memory, read as the editor that wrote it +/// meant it, or the current setting plus the step's own history inputs. Also returns lines for the +/// job log: an input that went unused, or a policy that remembers ending up stateless. +fn resolve_history_source<'a>( + args: &'a AIAgentArgs, + run_memory_id: Option, + workspace_id: &str, + flow_path: &str, +) -> (HistorySource<'a>, Vec<&'static str>) { + let mut notes = Vec::new(); + let no_memory_id = "No memory id was passed to this run, so the agent runs without memory."; + match &args.memory { + // The step's own history inputs came after these, so a step that still holds one reads it + // alone: what it did before the editor offered them is what it keeps doing. + Some(Memory::Manual { messages }) => { + note_unread_step_inputs(&mut notes, args); + (HistorySource::Messages(messages), notes) + } + Some(Memory::Auto { context_length, memory_id }) => { + note_unread_step_inputs(&mut notes, args); + // An id baked in at save time only ever applied when the run carried none. + match run_memory_id.or(*memory_id) { + Some(memory_id) => ( + HistorySource::Window { memory_id, context_length: *context_length }, + notes, + ), + None => { + notes.push(no_memory_id); + (HistorySource::Stateless, notes) + } + } + } + Some(Memory::Window { context_length }) => { + if args + .previous_messages + .as_ref() + .is_some_and(|messages| !messages.is_empty()) + { + notes.push("Managed memory is on, so this step's previous messages are ignored."); + } + let memory_id = match args.memory_id.as_deref() { + Some("") => { + notes.push( + "This step's memory id evaluated to an empty value, so the agent runs without memory.", + ); + return (HistorySource::Stateless, notes); + } + Some(step_memory_id) => memory_key(workspace_id, flow_path, step_memory_id), + None => match run_memory_id { + Some(memory_id) => memory_id, + None => { + notes.push(no_memory_id); + return (HistorySource::Stateless, notes); + } + }, + }; + ( + HistorySource::Window { memory_id, context_length: *context_length }, + notes, + ) + } + Some(Memory::Off) | None => { + if args.memory_id.as_deref().is_some_and(|id| !id.is_empty()) { + notes.push("Managed memory is off, so this step's memory id is ignored."); + } + match &args.previous_messages { + Some(messages) => (HistorySource::Messages(messages), notes), + None => (HistorySource::Stateless, notes), + } + } + } +} + +/// An older memory setting reads neither history input, which is only visible in the job log: the +/// editor offers them on a step that has been moved to the current settings. +fn note_unread_step_inputs(notes: &mut Vec<&'static str>, args: &AIAgentArgs) { + if args.memory_id.as_deref().is_some_and(|id| !id.is_empty()) { + notes.push("This step uses an older memory setting, so its memory id is not read."); + } + if args + .previous_messages + .as_ref() + .is_some_and(|messages| !messages.is_empty()) + { + notes + .push("This step uses an older memory setting, so its previous messages are not read."); + } +} + +/// Whether a request has something to ask the model. Only text output sends previous messages, so +/// an image prompt comes from the user message alone. An empty list is no conversation, except +/// under a legacy `manual` memory, which ran on whatever list it held. +fn has_prompt( + history: &HistorySource, + has_user_message: bool, + is_text_output: bool, + legacy_list: bool, +) -> bool { + has_user_message + || (is_text_output + && (legacy_list || matches!(history, HistorySource::Messages(m) if !m.is_empty()))) +} + fn find_module_by_id( modules: &Vec, target_id: &str, @@ -136,14 +278,16 @@ async fn find_ai_agent_tool_module_in_parent_agent( return Ok(None); }; - let FlowModuleValue::AIAgent { tools, agent, .. } = parent_agent_module.get_value()? else { + let FlowModuleValue::AIAgent { tools, agent, tool_inputs, .. } = + parent_agent_module.get_value()? + else { return Ok(None); }; // A linked parent carries no tools on the module (they live in the resource, resolved only in // the main execution branch). Resolve them from the resource here too, so a nested agent tool // of a saved+linked agent can still be located when it runs as its own job. - let tools = if let Some(agent_ref) = agent.as_deref() { + let mut tools = if let Some(agent_ref) = agent.as_deref() { let agent_path = agent_ref .trim_start_matches("$res:") .trim_start_matches("res://"); @@ -170,6 +314,9 @@ async fn find_ai_agent_tool_module_in_parent_agent( } else { tools }; + // The nested job reads its history inputs from the tool's transforms, which must carry the + // host flow's bindings as the parent evaluated them. + overlay_tool_inputs(&mut tools, &tool_inputs); for tool in tools { if tool.id == tool_module_id { @@ -456,6 +603,7 @@ pub async fn handle_ai_agent_job( omit_output_from_conversation, agent, tool_inputs, + input_transforms: step_input_transforms, .. } = module.get_value()? else { @@ -466,9 +614,11 @@ pub async fn handle_ai_agent_job( // A linked step takes its brain and tools from the resource and keeps only its own flow-local // inputs. The brain and the roster stay rigid; what the step binds to this flow is the message - // it asks, which of those tools this use may call, the conversation it is part of, and the - // tools' own inputs — the last overlaid from `tool_inputs` below. - let (args, tools): (AIAgentArgs, Vec) = if let Some(agent_ref) = agent.as_deref() { + // it asks, which of those tools this use may call, the conversation it is part of (its memory + // id and previous messages), and the tools' own inputs — the last overlaid from `tool_inputs` + // below. + let (mut args, tools): (AIAgentArgs, Vec) = if let Some(agent_ref) = agent.as_deref() + { let agent_path = agent_ref .trim_start_matches("$res:") .trim_start_matches("res://"); @@ -500,6 +650,12 @@ pub async fn handle_ai_agent_job( None => Vec::new(), }; overlay_tool_inputs(&mut tools, &tool_inputs); + // The resource is not validated against a schema, so a history input it happens to carry + // is dropped before interpolation, where a bad `$res:` in it would fail the step. The + // other flow-local keys stay: a resource's own user message is the step's fallback. + for key in STEP_HISTORY_KEYS { + config.remove(key); + } let brain = transform_json_value( "ai_agent", client, @@ -521,7 +677,7 @@ pub async fn handle_ai_agent_job( // Only after interpolating the resource: these are caller-controlled and already resolved by // build_args_map, so passing them through it again would expand contextual values — // `$WM_TOKEN` in a user message would reach the model provider. - for key in ["user_message", "user_attachments", "enabled_tools"] { + for key in FLOW_LOCAL_AGENT_KEYS { if let Some(v) = local_args.get(key) { brain.insert( key.to_string(), @@ -546,6 +702,8 @@ pub async fn handle_ai_agent_job( (args, tools) }; + keep_authored_memory_id(&mut args, &step_input_transforms); + // Nesting is capped at flow → agent → nested agent. When this job is itself a nested tool, // a linked resource's tool set may still contain AIAgent tools (the editor can't constrain a // shared resource); don't advertise them — invoking one would only fail the depth check as a @@ -1010,8 +1168,18 @@ pub async fn run_agent( // Fetch flow context for input transforms context, chat and memory let mut flow_context = get_flow_context(db, job).await; - // Determine if we're using manual messages (which bypasses memory) - let use_manual_messages = matches!(args.memory, Some(Memory::Manual { .. })); + // The run's memory id is also the chat conversation id, which a step's own memory id never + // replaces. + let conversation_id = flow_context + .flow_status + .as_ref() + .and_then(|fs| fs.memory_id); + let (history, history_notes) = resolve_history_source( + args, + conversation_id, + &job.workspace_id, + flow_context.flow_path.as_deref().unwrap_or_default(), + ); // Check if user_message is provided and non-empty let has_user_message = args @@ -1020,63 +1188,63 @@ pub async fn run_agent( .map(|m| !m.is_empty()) .unwrap_or(false); - // Validate: at least one of memory with manual messages or user_message must be provided - if !use_manual_messages && !has_user_message { - return Err(Error::internal_err( - "Either 'memory' with manual messages or 'user_message' must be provided".to_string(), - )); - } - let is_text_output = output_type == &OutputType::Text; - // Flow-level memory_id (from chat mode) takes precedence over step-level memory_id - let memory_id = flow_context - .flow_status - .as_ref() - .and_then(|fs| fs.memory_id) - .or_else(|| { - // Extract memory_id from Memory::Auto if present - match &args.memory { - Some(Memory::Auto { memory_id, .. }) => *memory_id, - _ => None, - } - }); + if is_text_output { + for note in &history_notes { + append_logs(&job.id, &job.workspace_id, format!("{note}\n"), conn).await; + } + } else if !matches!(args.memory, None | Some(Memory::Off)) + || args.memory_id.is_some() + || args.previous_messages.is_some() + { + append_logs( + &job.id, + &job.workspace_id, + "Image output sends no history, so memory and previous messages are not read.\n", + conn, + ) + .await; + } + + // A `manual` memory sent whatever list it held, an empty one included, so a step that still has + // one keeps running without a user message. + let legacy_list = matches!(args.memory, Some(Memory::Manual { .. })); + if !has_prompt(&history, has_user_message, is_text_output, legacy_list) { + let missing = if !is_text_output { + "'user_message' must be provided for image output" + } else if matches!( + args.memory, + Some(Memory::Window { .. } | Memory::Auto { .. }) + ) { + "'user_message' must be provided while managed memory is on" + } else { + "Either 'previous_messages' or 'user_message' must be provided" + }; + return Err(Error::internal_err(missing.to_string())); + } - // Load messages based on history mode if matches!(output_type, OutputType::Text) { - match &args.memory { - Some(Memory::Manual { messages: manual_messages }) => { - // Use explicitly provided messages (bypass memory) - if !manual_messages.is_empty() { - messages.extend(manual_messages.clone()); - } - } - Some(Memory::Auto { context_length, .. }) => { - // Auto mode: load from memory + match &history { + HistorySource::Messages(provided) => messages.extend(provided.iter().cloned()), + HistorySource::Window { memory_id, context_length } => { if let Some(step_id) = effective_flow_step_id { - if let Some(memory_id) = memory_id { - // Read messages from memory - match read_from_memory(db, &job.workspace_id, memory_id, step_id).await { - Ok(Some(loaded_messages)) => { - let messages_to_load = prepare_auto_memory_messages_for_request( - &loaded_messages, - *context_length, - ); - messages.extend(messages_to_load); - } - Ok(None) => {} - Err(e) => { - tracing::error!( - "Failed to read memory for step {}: {}", - step_id, - e - ); - } + match read_from_memory(db, &job.workspace_id, *memory_id, step_id).await { + Ok(Some(loaded_messages)) => { + let messages_to_load = prepare_auto_memory_messages_for_request( + &loaded_messages, + *context_length, + ); + messages.extend(messages_to_load); + } + Ok(None) => {} + Err(e) => { + tracing::error!("Failed to read memory for step {}: {}", step_id, e); } } } } - _ => {} + HistorySource::Stateless => {} } } @@ -1521,30 +1689,35 @@ pub async fn run_agent( ..Default::default() }); if persist_output_to_conversation { - if let Some(memory_id) = memory_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, - &memory_id, - Some(agent_job_id), - &message_content, - MessageType::Tool, - &step_name, - true, - ) - .await - { - tracing::warn!( - "Failed to add websearch tool message to conversation {}: {}", - memory_id, - e - ); - } + if let Some(conversation_id) = conversation_id { + // 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 + ); + } } } } @@ -1573,32 +1746,30 @@ 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(memory_id) = memory_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, - &memory_id, - Some(agent_job_id), - &message_content, - MessageType::Assistant, - &step_name, - true, - ) - .await - { - tracing::warn!( - "Failed to add assistant message to conversation {}: {}", - memory_id, - e - ); - } + if let Some(conversation_id) = conversation_id { + // 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 + ); + } } } } @@ -1637,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, @@ -1655,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(), @@ -1673,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); } @@ -1692,10 +1915,7 @@ pub async fn run_agent( // Add assistant message to conversation if chat_input_enabled if persist_output_to_conversation { - if let Some(memory_id) = memory_id { - let agent_job_id = job.id; - let db_clone = db.clone(); - + if let Some(conversation_id) = conversation_id { // 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 { @@ -1706,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, - &memory_id, - Some(agent_job_id), - &message_content, - MessageType::Assistant, - &step_name, - true, - ) - .await - { - tracing::warn!( - "Failed to add assistant message to conversation {}: {}", - memory_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 + ); + } } } @@ -1778,13 +1996,10 @@ pub async fn run_agent( } } - // Persist complete conversation to memory at the end (only if in auto mode with context length) - // Skip memory persistence if using manual messages (bypass memory entirely) - // final_messages contains the complete history (old messages + new ones) - if matches!(output_type, OutputType::Text) && !use_manual_messages { - if let Some(Memory::Auto { context_length, .. }) = &args.memory { + // final_messages holds the complete history: what was loaded plus this run's messages + if matches!(output_type, OutputType::Text) { + if let HistorySource::Window { memory_id, context_length } = &history { if let Some(step_id) = effective_flow_step_id { - // Extract OpenAIMessages from final_messages let all_messages: Vec = final_messages.iter().map(|m| m.message.clone()).collect(); @@ -1794,23 +2009,21 @@ pub async fn run_agent( *context_length, ); - if let Some(memory_id) = memory_id { - if let Err(e) = write_to_memory( - db, - &job.workspace_id, - memory_id, + if let Err(e) = write_to_memory( + db, + &job.workspace_id, + *memory_id, + step_id, + &messages_to_persist, + ) + .await + { + tracing::error!( + "Failed to persist {} messages to memory for step {}: {}", + messages_to_persist.len(), step_id, - &messages_to_persist, - ) - .await - { - tracing::error!( - "Failed to persist {} messages to memory for step {}: {}", - messages_to_persist.len(), - step_id, - e - ); - } + e + ); } } } @@ -1870,6 +2083,228 @@ mod tests { } } + #[derive(Debug, PartialEq)] + enum Resolved { + Messages(usize), + Window(Uuid, usize), + Stateless { noted: bool }, + } + + /// Every memory shape a worker may still read, resolved against a run with or without a + /// memory id. The hashed id is pinned: changing it detaches memories stored under string ids. + #[test] + fn history_source_resolves_every_memory_shape() { + use serde_json::json; + let run = Uuid::from_u128(1); + let baked = Uuid::from_u128(2); + let cust_1 = Uuid::parse_str("0168fcea-ffa7-5c15-bdb0-7709bb5f540d").unwrap(); + let window = json!({ "kind": "window", "context_length": 10 }); + let message = json!([{ "role": "user", "content": "earlier" }]); + let two_messages = json!([ + { "role": "user", "content": "earlier" }, + { "role": "assistant", "content": "reply" } + ]); + let cases = [ + ( + "absent memory is off", + json!({}), + Some(run), + Resolved::Stateless { noted: false }, + ), + ( + "legacy off", + json!({ "memory": { "kind": "off" } }), + Some(run), + Resolved::Stateless { noted: false }, + ), + ( + "legacy auto prefers the run's id", + json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked } }), + Some(run), + Resolved::Window(run, 4), + ), + ( + "legacy auto falls back to its baked id", + json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked } }), + None, + Resolved::Window(baked, 4), + ), + ( + "legacy auto with an empty baked id uses the run's", + json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": "" } }), + Some(run), + Resolved::Window(run, 4), + ), + ( + "legacy auto with an empty baked id and no run id is stateless", + json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": " " } }), + None, + Resolved::Stateless { noted: true }, + ), + ( + "legacy auto without a length is off", + json!({ "memory": { "kind": "auto", "memory_id": baked } }), + Some(run), + Resolved::Stateless { noted: false }, + ), + ( + "a cleared count is off", + json!({ "memory": { "kind": "window", "context_length": null } }), + Some(run), + Resolved::Stateless { noted: false }, + ), + ( + "legacy manual replays its messages", + json!({ "memory": { "kind": "manual", "messages": message } }), + Some(run), + Resolved::Messages(1), + ), + ( + "window keeps the run's memory", + json!({ "memory": window }), + Some(run), + Resolved::Window(run, 10), + ), + ( + "window without a memory id is stateless", + json!({ "memory": window }), + None, + Resolved::Stateless { noted: true }, + ), + ( + "a step memory id overrides the run's", + json!({ "memory": window, "memory_id": "cust_1" }), + Some(run), + Resolved::Window(cust_1, 10), + ), + ( + "a uuid step memory id is used as is", + json!({ "memory": window, "memory_id": baked.to_string() }), + Some(run), + Resolved::Window(baked, 10), + ), + ( + "a step memory id evaluating to null is stateless", + json!({ "memory": window, "memory_id": null }), + Some(run), + Resolved::Stateless { noted: true }, + ), + ( + "an off policy ignores the step memory id, and says so", + json!({ "memory": { "kind": "off" }, "memory_id": "cust_1" }), + Some(run), + Resolved::Stateless { noted: true }, + ), + ( + "managed memory ignores the step's previous messages", + json!({ "memory": window, "memory_id": "cust_1", "previous_messages": message }), + Some(run), + Resolved::Window(cust_1, 10), + ), + ( + "memory that is off sends the step's previous messages", + json!({ "previous_messages": message }), + Some(run), + Resolved::Messages(1), + ), + ( + "a previous messages expression that evaluated to null is no history", + json!({ "previous_messages": null }), + Some(run), + Resolved::Stateless { noted: false }, + ), + ( + "a legacy manual list ignores the step's previous messages", + json!({ "memory": { "kind": "manual", "messages": message }, "previous_messages": two_messages }), + Some(run), + Resolved::Messages(1), + ), + ( + "legacy auto ignores a step memory id", + json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked }, "memory_id": "cust_1" }), + None, + Resolved::Window(baked, 4), + ), + ]; + for (name, history, run_memory_id, expected) in cases { + let mut raw = json!({ "provider": { "kind": "openai", "resource": {}, "model": "m" } }); + raw.as_object_mut() + .unwrap() + .extend(history.as_object().unwrap().clone()); + let args: AIAgentArgs = serde_json::from_value(raw).unwrap(); + let resolved = match resolve_history_source(&args, run_memory_id, "ws", "f/flow") { + (HistorySource::Messages(m), _) => Resolved::Messages(m.len()), + (HistorySource::Window { memory_id, context_length }, _) => { + Resolved::Window(memory_id, context_length) + } + (HistorySource::Stateless, notes) => { + Resolved::Stateless { noted: !notes.is_empty() } + } + }; + assert_eq!(resolved, expected, "{name}"); + } + } + + /// A placeholder the form seeds must not read as a memory id that evaluated to nothing, which + /// would turn memory off for the step. + #[test] + fn only_an_expression_can_set_an_empty_step_memory_id() { + let transforms = |memory_id: &str| -> HashMap { + HashMap::from([( + "memory_id".to_string(), + serde_json::from_str(memory_id).unwrap(), + )]) + }; + let args = || -> AIAgentArgs { + serde_json::from_value(serde_json::json!({ + "provider": { "kind": "openai", "resource": {}, "model": "m" }, + "memory_id": null, + })) + .unwrap() + }; + for (transform, expected) in [ + (r#"{ "type": "static" }"#, None), + (r#"{ "type": "static", "value": "" }"#, None), + (r#"{ "type": "ai" }"#, None), + ( + r#"{ "type": "javascript", "expr": "flow_input.customer_id" }"#, + Some(""), + ), + ] { + let mut args = args(); + keep_authored_memory_id(&mut args, &transforms(transform)); + assert_eq!(args.memory_id.as_deref(), expected, "{transform}"); + } + } + + /// Only text output sends previous messages, so they never stand in for an image prompt. + #[test] + fn previous_messages_never_stand_in_for_an_image_prompt() { + let args: AIAgentArgs = serde_json::from_value(serde_json::json!({ + "provider": { "kind": "openai", "resource": {}, "model": "m" }, + "previous_messages": [{ "role": "user", "content": "earlier" }], + })) + .unwrap(); + let (history, _) = resolve_history_source(&args, None, "ws", "f/flow"); + assert!(has_prompt(&history, false, true, false)); + assert!(!has_prompt(&history, false, false, false)); + assert!(has_prompt(&history, true, false, false)); + assert!(!has_prompt( + &HistorySource::Messages(&[]), + false, + true, + false + )); + // A legacy `manual` memory ran on an empty list alone, and still does for text output. + assert!(has_prompt(&HistorySource::Messages(&[]), false, true, true)); + assert!(!has_prompt( + &HistorySource::Messages(&[]), + false, + false, + true + )); + } + #[test] fn reasoning_keeps_every_iteration_in_order() { let mut acc = String::new(); 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/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/benchmarks/lib.ts b/benchmarks/lib.ts index 9159434fd0..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.812.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/README.md b/chat-sdk/README.md index a233425d14..1d09b68265 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 @@ -181,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. | @@ -224,12 +226,41 @@ 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? })`, -`deleteConversation(id)`, `loadOlderMessages()`, `destroy()`. Switching conversations +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 +`isTest`. A rename keeps the conversation's place in the list. Switching conversations 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/package-lock.json b/chat-sdk/package-lock.json index 2fe22bcfbc..83475ba1c8 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.812.0", + "version": "1.814.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.812.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 a5aff2c90b..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.812.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/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', @@ -167,15 +194,25 @@ export class WindmillChatApi { async listConversations( flowPath: string, - options: { page?: number; perPage?: number; signal?: AbortSignal } = {} + options: { page?: number; perPage?: number; kind?: ConversationKind; signal?: AbortSignal } = {} ): Promise { + const extra: Record = { flow_path: flowPath } + if (options.kind !== undefined) extra.kind = options.kind const res = await this.#request('flow_conversations/list', { - query: pagination(options, { flow_path: flowPath }), + query: pagination(options, extra), signal: options.signal }) return (await res.json()) as FlowConversation[] } + /** Sets a conversation's title. Its place in the list is kept: only a turn moves one. */ + async renameConversation(conversationId: string, title: string): Promise { + await this.#request(`flow_conversations/update/${encodeURIComponent(conversationId)}`, { + method: 'POST', + body: { title } + }) + } + /** * Without `afterSeq`: one page counted from the newest message, returned oldest first. * With `afterSeq`: the messages created after that cursor, oldest first. @@ -193,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' @@ -204,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 } = {} @@ -215,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/assistant-ui.ts b/chat-sdk/src/assistant-ui.ts index ef22f28531..b9a76db679 100644 --- a/chat-sdk/src/assistant-ui.ts +++ b/chat-sdk/src/assistant-ui.ts @@ -53,7 +53,8 @@ export function useWindmillRuntime(options: WindmillRuntimeOptions): AssistantRu threads: chat.conversations.map((c) => ({ status: 'regular' as const, id: c.id, title: c.title })), onSwitchToNewThread: () => chat.newConversation(), onSwitchToThread: (id) => chat.selectConversation(id), - onDelete: (id) => chat.deleteConversation(id) + onDelete: (id) => chat.deleteConversation(id), + onRename: (id, title) => chat.renameConversation(id, title) } } : undefined @@ -91,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({ @@ -99,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/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 dda8f10d05..2e2c52f3df 100644 --- a/chat-sdk/src/chat.ts +++ b/chat-sdk/src/chat.ts @@ -1,12 +1,14 @@ import { WindmillApiError, WindmillChatApi, + type ConversationKind, type FlowConversation, type FlowConversationMessage } from './api' 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, @@ -14,12 +16,15 @@ import type { ChatOptions, ChatState, Conversation, + SendMessageOptions, ToolInvocation } from './types' import { conversationTitle, + truncateTitle, errorResultMessage, extractChatAnswer, + abortError, isAbortError, isErrorResult, now, @@ -39,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 @@ -59,6 +70,8 @@ class ChatImpl implements Chat { #state: ChatState #turn: Turn | undefined #page = 1 + /** The kind the caller last listed, so the refresh after a new turn lists the same rows. */ + #conversationKind: ConversationKind | undefined #persistTimer: ReturnType | undefined constructor(options: ChatOptions) { @@ -97,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 @@ -123,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 } @@ -131,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) @@ -148,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) @@ -160,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() @@ -229,15 +289,21 @@ class ChatImpl implements Chat { } loadConversations = async ( - options: { page?: number; perPage?: number } = {} + options: { page?: number; perPage?: number; kind?: ConversationKind } = {} ): Promise => { const page = options.page ?? 1 + // A different kind is a different listing: its first rows replace the held ones, on + // whichever page they were asked for. + const kindChanged = 'kind' in options && options.kind !== this.#conversationKind + if ('kind' in options) this.#conversationKind = options.kind + const kind = this.#conversationKind let conversations: Conversation[] if (this.#state.history === 'server') { try { const rows = await this.#api.listConversations(this.#config.flowPath, { page, - perPage: options.perPage ?? this.#config.pageSize + perPage: options.perPage ?? this.#config.pageSize, + kind }) conversations = rows.map(fromConversation) } catch (e) { @@ -247,10 +313,13 @@ class ChatImpl implements Chat { } else { conversations = this.#state.history === 'local' ? this.#local.listConversations() : [] } + // Another kind was asked for while this list was on its way: its rows are not the + // listing any more, whichever response lands last. + if (kind !== this.#conversationKind) return conversations const known = new Set(this.#state.conversations.map((c) => c.id)) this.#set({ conversations: - page === 1 + page === 1 || kindChanged ? conversations : [...this.#state.conversations, ...conversations.filter((c) => !known.has(c.id))] }) @@ -273,6 +342,24 @@ class ChatImpl implements Chat { this.#set({ conversations: this.#state.conversations.filter((c) => c.id !== conversationId) }) } + renameConversation = async (conversationId: string, title: string): Promise => { + // Cut here as the server cuts, so the title shown is the one stored. + const trimmed = truncateTitle(title.trim()) + if (!trimmed) return + if (this.#state.history === 'server') { + await this.#api.renameConversation(conversationId, trimmed) + } else if (this.#state.history === 'local') { + this.#local.renameConversation(conversationId, trimmed) + } + // Patched in place: the server keeps `updated_at` on a rename, so the list order the + // next load returns is the one shown now. + this.#set({ + conversations: this.#state.conversations.map((c) => + c.id === conversationId ? { ...c, title: trimmed } : c + ) + }) + } + loadOlderMessages = async (): Promise => { const conversationId = this.#state.conversationId if ( @@ -344,16 +431,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 @@ -388,17 +480,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': @@ -545,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) @@ -612,22 +731,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 }) @@ -725,7 +876,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 } } @@ -734,7 +897,8 @@ function fromConversation(row: FlowConversation): Conversation { id: row.id, title: row.title ?? undefined, createdAt: row.created_at, - updatedAt: row.updated_at + updatedAt: row.updated_at, + isTest: row.is_test } } diff --git a/chat-sdk/src/history.ts b/chat-sdk/src/history.ts index 485dd056cf..b2eb124e8f 100644 --- a/chat-sdk/src/history.ts +++ b/chat-sdk/src/history.ts @@ -4,6 +4,8 @@ export interface LocalHistory { listConversations(): Conversation[] getMessages(conversationId: string): ChatMessage[] upsertConversation(conversation: Conversation): void + /** Changes a stored conversation's title in place; unlike `upsertConversation`, its position is kept. */ + renameConversation(conversationId: string, title: string): void saveMessages(conversationId: string, messages: ChatMessage[]): void deleteConversation(conversationId: string): void } @@ -55,6 +57,11 @@ export function createLocalHistory(storage: StorageLike | undefined, key: string } write(s) }, + renameConversation(id, title) { + const s = read() + s.conversations = s.conversations.map((c) => (c.id === id ? { ...c, title } : c)) + write(s) + }, saveMessages(id, messages) { const s = read() s.messages[id] = messages.map((m) => ({ ...m, pending: false })) diff --git a/chat-sdk/src/index.ts b/chat-sdk/src/index.ts index 353c814154..9ed2ed0b7e 100644 --- a/chat-sdk/src/index.ts +++ b/chat-sdk/src/index.ts @@ -5,6 +5,7 @@ export { WindmillApiError, readServerSentEvents, type WindmillChatApiOptions, + type ConversationKind, type FlowConversation, type FlowConversationMessage, type JobUpdateEvent, @@ -13,8 +14,12 @@ 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, ChatOptions, ChatRole, @@ -23,6 +28,7 @@ export type { Conversation, FetchLike, HistoryMode, + SendMessageOptions, StorageLike, TokenSource, ToolInvocation diff --git a/chat-sdk/src/react.ts b/chat-sdk/src/react.ts index ecba159cb3..33193842f2 100644 --- a/chat-sdk/src/react.ts +++ b/chat-sdk/src/react.ts @@ -11,6 +11,7 @@ export type UseWindmillChat = ChatState & | 'selectConversation' | 'loadConversations' | 'deleteConversation' + | 'renameConversation' | 'loadOlderMessages' > & { chat: Chat } @@ -65,6 +66,7 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat { selectConversation: chat.selectConversation, loadConversations: chat.loadConversations, deleteConversation: chat.deleteConversation, + renameConversation: chat.renameConversation, loadOlderMessages: chat.loadOlderMessages }), [state, chat] diff --git a/chat-sdk/src/types.ts b/chat-sdk/src/types.ts index 92b8dfb89b..99e16fe304 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. */ @@ -52,6 +56,11 @@ export interface Conversation { title: string | undefined createdAt: string updatedAt: string + /** + * Started from the flow editor's test panel rather than a deployed run. Known once the + * server has listed the conversation; unset for one only this client has seen. + */ + isTest?: boolean } export interface ChatState { @@ -118,18 +127,61 @@ 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 selectConversation(conversationId: string): Promise - loadConversations(options?: { page?: number; perPage?: number }): Promise + /** + * `kind` narrows server history to the flow editor's test chats, the deployed flow's + * own (the server's default), or both. Local history has no test chats and ignores it. + */ + loadConversations(options?: { + page?: number + perPage?: number + kind?: 'test' | 'deployed' | 'all' + }): Promise deleteConversation(conversationId: string): Promise + /** Sets a conversation's title. The list keeps its order: only a turn moves a conversation. */ + renameConversation(conversationId: string, title: string): Promise loadOlderMessages(): Promise /** Stops background work (stream, polling) and writes local history out. The chat stays usable. */ destroy(): void diff --git a/chat-sdk/src/utils.ts b/chat-sdk/src/utils.ts index fe7e5d8960..7973914e93 100644 --- a/chat-sdk/src/utils.ts +++ b/chat-sdk/src/utils.ts @@ -69,6 +69,12 @@ export function conversationTitle(firstMessage: string): string { return chars.length > 25 ? `${chars.slice(0, 25).join('')}...` : firstMessage } +/** The server's bound on a typed title: 252 characters plus an ellipsis fits its 255-char column. */ +export function truncateTitle(title: string): string { + const chars = Array.from(title) + return chars.length > 252 ? `${chars.slice(0, 252).join('')}...` : title +} + export function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(abortError()) 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/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/chat.test.ts b/chat-sdk/test/chat.test.ts index 1a5be8375d..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( @@ -414,6 +521,112 @@ describe('createChat with server history', () => { expect(chat.getState().conversations.map((c) => c.id)).toEqual(['c1', 'c2']) }) + test('lists one kind of conversation and carries which kind each one is', async () => { + const row = (id: string, is_test: boolean) => ({ + id, + workspace_id: 'ws', + flow_path: FLOW, + title: id, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + created_by: 'admin', + is_test + }) + const { fetch, calls } = fetchMock((c) => + c.url.pathname === '/api/w/ws/flow_conversations/list' + ? json(c.url.searchParams.get('kind') === 'test' ? [row('t1', true)] : [row('d1', false)]) + : undefined + ) + const chat = createChat(options({}, fetch)) + await chat.loadConversations() + expect(calls[0].url.searchParams.has('kind')).toBe(false) + expect(chat.getState().conversations.map((c) => [c.id, c.isTest])).toEqual([['d1', false]]) + await chat.loadConversations({ kind: 'test' }) + expect(calls[1].url.searchParams.get('kind')).toBe('test') + expect(chat.getState().conversations.map((c) => [c.id, c.isTest])).toEqual([['t1', true]]) + }) + + test('a list for a kind no longer asked for does not replace the newer one', async () => { + const row = (id: string, is_test: boolean) => ({ + id, + workspace_id: 'ws', + flow_path: FLOW, + title: id, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + created_by: 'admin', + is_test + }) + const { fetch } = fetchMock((c) => { + if (c.url.pathname !== '/api/w/ws/flow_conversations/list') return undefined + if (c.url.searchParams.get('kind') === 'test') { + return new Promise((r) => setTimeout(() => r(json([row('t1', true)])), 50)) + } + return json([row('d1', false)]) + }) + const chat = createChat(options({}, fetch)) + const slow = chat.loadConversations({ kind: 'test' }) + await chat.loadConversations({ kind: 'deployed' }) + await slow + expect(chat.getState().conversations.map((c) => c.id)).toEqual(['d1']) + // Another kind asked for on a later page starts its own listing rather than appending. + await chat.loadConversations({ page: 2, kind: 'test' }) + expect(chat.getState().conversations.map((c) => c.id)).toEqual(['t1']) + }) + + test('the refresh after a new turn lists the kind last asked for', async () => { + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { output: 'Hello', messages: [] } }]) + : undefined, + (c) => + c.method === 'GET' && c.url.pathname.endsWith('/messages') + ? json([messageRow(11, 'user', 'hi'), messageRow(12, 'assistant', 'Hello', { job_id: 'agent-job' })]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.loadConversations({ kind: 'test' }) + await chat.sendMessage('hi') + const lists = calls.filter((c) => c.url.pathname === '/api/w/ws/flow_conversations/list') + expect(lists.length).toBeGreaterThan(1) + expect(lists.every((c) => c.url.searchParams.get('kind') === 'test')).toBe(true) + }) + + test('renaming a conversation keeps its place in the list', async () => { + const row = (id: string) => ({ + id, + workspace_id: 'ws', + flow_path: FLOW, + title: id, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + created_by: 'admin', + is_test: false + }) + const { fetch, calls } = fetchMock( + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([row('c1'), row('c2')]) : undefined), + (c) => + c.method === 'POST' && c.url.pathname === '/api/w/ws/flow_conversations/update/c2' + ? text('Conversation c2 updated') + : undefined + ) + const chat = createChat(options({}, fetch)) + await chat.loadConversations() + await chat.renameConversation('c2', ' Budget review ') + expect(calls[1].body).toEqual({ title: 'Budget review' }) + expect(chat.getState().conversations.map((c) => [c.id, c.title])).toEqual([ + ['c1', 'c1'], + ['c2', 'Budget review'] + ]) + // Cut as the server cuts, so what is shown is what is stored. + await chat.renameConversation('c2', 'x'.repeat(300)) + expect(chat.getState().conversations[1].title).toBe('x'.repeat(252) + '...') + expect(calls[2].body).toEqual({ title: 'x'.repeat(252) + '...' }) + }) + test('a turn started right after stop() is not touched by the stop sync', async () => { let jobs = 0 const { fetch } = fetchMock( @@ -583,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 @@ -726,6 +1053,27 @@ describe('createChat with server history', () => { expect((await again.loadConversations()).map((c) => c.id)).toEqual([newer, older]) }) + test('renaming a local conversation persists the title without reordering history', async () => { + const storage = memoryStorage() + const { fetch, calls } = fetchMock(run, (c) => + c.url.pathname === streamPath ? sse([{ type: 'update', completed: true, only_result: 'ok' }]) : undefined + ) + const chat = createChat(options({ token: 'tok', storage }, fetch)) + await chat.sendMessage('older') + const older = chat.getState().conversationId! + chat.newConversation() + await chat.sendMessage('newer') + const newer = chat.getState().conversationId! + const before = calls.length + await chat.renameConversation(older, 'Renamed') + expect(calls.length).toBe(before) + const again = createChat(options({ token: 'tok', storage }, fetch)) + expect((await again.loadConversations()).map((c) => [c.id, c.title])).toEqual([ + [newer, 'newer'], + [older, 'Renamed'] + ]) + }) + test('destroying the chat mid-turn leaves it idle', async () => { const { fetch } = fetchMock(run, (c) => c.url.pathname === streamPath 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/cli/src/core/constants.ts b/cli/src/core/constants.ts index 8c01755492..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.812.0"; +export const VERSION = "1.814.0"; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index f58092fdac..035c25a574 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5276,9 +5276,60 @@ 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 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 +{ + "id": "chat_agent", + "value": { + "type": "aiagent", + "input_transforms": { + "provider": { + "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" }, + "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, + "streaming": { "type": "static", "value": true }, + "output_type": { "type": "static", "value": "text" } + }, + "tools": [] + } +} +\`\`\` + +- 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 + (\`{ "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 @@ -5582,7 +5633,7 @@ 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"]}}`, +{"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"]}}`, "raw-app": `--- name: raw-app description: MUST use when creating raw apps. 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/docs/reusable-ai-agents.md b/docs/reusable-ai-agents.md index edb49d75a1..2f703bc0b5 100644 --- a/docs/reusable-ai-agents.md +++ b/docs/reusable-ai-agents.md @@ -16,11 +16,12 @@ every workspace via the standard cached-resource-type sync, like other built-in - The brain config and tools are resolved at runtime from the resource (`windmill-worker/src/ai_executor.rs`): the brain is interpolated, so a nested provider `$res:` credential resolves automatically. -- The step keeps only the flow-local inputs (`user_message`, `user_attachments`, `enabled_tools`) - in its own `input_transforms`; the brain and tools stay in the resource (read-only in the step). - `enabled_tools` says which of the roster this step may call, narrowing one use of a shared agent - without touching the agent: an absent field carries every tool, a list carries the ones it names, - and an empty list carries none. +- The step keeps only the flow-local inputs (`user_message`, `user_attachments`, `enabled_tools`, + and the history inputs `memory_id` and `previous_messages`) in its own `input_transforms`; the + brain and tools stay in the resource (read-only in the step). `enabled_tools` says which of the + roster this step may call, narrowing one use of a shared agent without touching the agent: an + absent field carries every tool, a list carries the ones it names, and an empty list carries + none. - The agent carries its tools' default input bindings verbatim as authored (static, AI-filled, or flow expressions), so saving round-trips losslessly. Each host flow overrides what it needs: `tool_inputs` stores per-tool overrides (a diff from the resource tool's own @@ -36,6 +37,58 @@ agent step); below the step's inputs, each tool gets a section with the standard input editors (prop picker included) and a read-only view of its code — edits persist into `tool_inputs`. +## Memory + +Memory is split between three owners, so a saved agent carries whether it remembers and never +which memory it is: + +- **Agent: managed memory.** `memory` is a brain key, so it moves with a saved agent. + `{ kind: window, context_length }` has Windmill store the conversation and replay its last N + messages; `{ kind: off }` keeps none. An absent `memory` means off, the default: the editor turns + it on when chat input is enabled. `auto` and `manual` are the older spellings and are still read. +- **Run: memory id.** `flow_status.memory_id`, set when the run is queued: the chat conversation + id, an app chat session id, or the `memory_id` run parameter. Any string is accepted, and one + that is not a uuid is hashed to a v5 uuid scoped to the workspace and the flow the run started + from (`memory_key` in `windmill-common/src/flow_conversations.rs`), so the same key in two flows + names two memories. A uuid is used as is. Nothing is generated at save time, so schedules, + webhooks, evals and plain runs pass no id and run stateless. +- **Step: history inputs.** Flow-local, so they stay on a linked step. Each is read in one memory + state only, and the editor offers it only there, the memory id behind a *Custom* toggle that + writes the key only once it is on. With managed memory on, `memory_id` overrides the run's id, + hashed the same way: a fixed value is one memory shared by every run, an expression such as + `flow_input.customer_id` one memory per key, and an expression that evaluates to nothing runs + stateless rather than falling back to the run's id. With memory off, `previous_messages` supplies + the history itself. An older `auto` or `manual` memory reads neither, so the editor offers them + only once the step is moved to the current settings, which the alert's button does. The editor + never seeds a placeholder for either, because a present key is the step's choice, and a static + empty value reads as unset. + +The worker reconciles them once per agent invocation, nested agent tools included, in +`resolve_history_source` (`windmill-worker/src/ai_executor.rs`): + +1. A legacy `auto` or `manual` memory: read as the editor that wrote it ran it. `manual` replays + its list; `auto` uses the run's memory id, else the id baked into it, else runs stateless. + Neither history input is read. An `auto` without a count, or with 0, is off and read as such. +2. Managed memory: the memory id is the step's, else the run's. With no memory id the agent runs + stateless, and a step `previous_messages` is ignored. +3. Memory off: the history is `previous_messages`, else nothing. Memory is neither read nor + written, and a step `memory_id` is ignored. + +Each ignored input and each stateless fallback is written to the job log. + +Memory is stored per (memory id, step id), in `ai_agent_memory` or S3 at +`memory/{workspace}/{memory id}/{step}.json`. The chat transcript (`flow_conversation_message`) +always follows the run's id, even when a step sets its own. Nothing expires stored memory: deleting +a chat conversation deletes its memory, and a memory named by a string id stays until it is +overwritten. + +Compatibility runs one way. New workers read every older shape. The editor rewrites a legacy step +only when the author changes it, so a flow nobody edits keeps running on older workers, while a +step saved with `window` or a history input needs a worker that knows them. An id an older editor +baked into `memory` stays a fallback behind the run's id until the author chooses *Keep as memory +id* or *Use the run's memory id*. In a chat flow it is dropped on save, since the conversation id +always took precedence there. + ## Drafts The agent editor edits the resource through a **per-user resource draft** (`draft` table, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c067498797..935a1aa475 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.812.0", + "version": "1.814.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.812.0", + "version": "1.814.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index fee148af8a..eedca8f2c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.812.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/frontend/src/lib/common.ts b/frontend/src/lib/common.ts index 0d76b1a0b8..9eb019e838 100644 --- a/frontend/src/lib/common.ts +++ b/frontend/src/lib/common.ts @@ -23,6 +23,8 @@ export interface SchemaProperty { pattern?: string default?: any enum?: EnumType + /** Display names by stored value, for an enum's options or a one-of's variants. */ + enumLabels?: Record contentEncoding?: 'base64' | 'binary' format?: string items?: { @@ -45,6 +47,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/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/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index b7001059c2..d62f8aed0f 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, @@ -206,6 +209,15 @@ let tagKey = $derived( oneOf?.find((o) => Object.keys(o.properties ?? {})?.includes('kind')) ? 'kind' : 'label' ) + // `oneOfSelected` is resynced in an effect, one pass after the variants or the value change. A + // variant that just left the list while the selection still names it, as when a value moves + // off a legacy kind the list offered only for it, would render the nested form against nothing + // for that pass and let it rewrite the value. The value's own tag settles it at once. + let effectiveOneOfSelected = $derived.by(() => { + if (oneOf?.some((o) => o.title === oneOfSelected)) return oneOfSelected + const tag = value?.[tagKey] + return oneOf?.some((o) => o.title === tag) ? tag : oneOfSelected + }) async function updateOneOfSelected(oneOf: SchemaProperty[] | undefined) { if ( oneOf && @@ -1104,11 +1116,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 = @@ -1136,12 +1152,16 @@ > {#snippet children({ item })} {#each oneOf as obj} - + {/each} {/snippet} - {#if oneOfSelected} - {@const objIdx = oneOf.findIndex((o) => o.title === oneOfSelected)} + {#if effectiveOneOfSelected} + {@const objIdx = oneOf.findIndex((o) => o.title === effectiveOneOfSelected)} {@const obj = oneOf[objIdx]} {#if obj && obj.properties && Object.keys(obj.properties).length > 0} {#key redraw} @@ -1156,10 +1176,10 @@ {workspace} bind:schema={ () => ({ - properties: obj.properties ?? {}, - order: obj.order, + properties: obj?.properties ?? {}, + order: obj?.order, $schema: '', - required: obj.required ?? [], + required: obj?.required ?? [], type: 'object' }), () => { @@ -1193,16 +1213,16 @@ {workspace} hiddenArgs={['label', 'kind']} schema={{ - properties: obj.properties, - order: obj.order, + properties: obj?.properties ?? {}, + order: obj?.order, $schema: '', - required: obj.required ?? [], + required: obj?.required ?? [], type: 'object' }} bind:args={ () => value, (v) => { - value = { ...v, [tagKey]: oneOfSelected } + value = { ...v, [tagKey]: effectiveOneOfSelected } } } {shouldDispatchChanges} 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} @@ -605,7 +609,7 @@ >
{argName} - {#if !uiOnly} + {#if !uiOnly && !locked}
{#snippet trigger()} @@ -654,7 +658,7 @@ Required {/if} - {#if !uiOnly} + {#if !uiOnly && !locked}
{:else if stepDetail == 'Input'} + {#if schema} {:else}

No input schema

{/if} {:else if stepDetail == 'Result'} +

End of the flow

{:else if typeof stepDetail != 'string' && stepDetail.value} + +
-
- {#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/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index f6f6da0cc9..32b46cb2be 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -470,9 +470,10 @@ ) return jobId ?? '' }} - hideSidebar={true} + conversationKind="test" path={$pathStore} inputSchema={flowStore.val.schema} + flowModules={flowStore.val.value?.modules} />
{:else} diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index c5ae65eda2..8d758ebf71 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -19,6 +19,7 @@ import DynamicInputHelpBox from './flows/content/DynamicInputHelpBox.svelte' import type { PropPickerWrapperContext } from './flows/propPicker/PropPickerWrapper.svelte' import { codeToStaticTemplate, getDefaultExpr } from './flows/utils.svelte' + import { keepsManagedMemory } from './flows/agentFormFields' import SimpleEditor from './SimpleEditor.svelte' import { Button, ButtonType } from '$lib/components/common' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' @@ -53,6 +54,8 @@ label?: string /** Replaces the label header, so a setting's own toggle can name the field. */ header?: Snippet + /** Indent the input under the header's label, for a header that starts with a switch. */ + indentUnderHeader?: boolean /** Renders after the label: a button to unset the field, a badge. */ labelExtra?: Snippet /** Drop the schema's description paragraph, for a form that carries it in a tooltip. */ @@ -119,6 +122,7 @@ argName = $bindable(), label = undefined, header = undefined, + indentUnderHeader = true, labelExtra = undefined, hideDescription = false, subtleControls = false, @@ -863,7 +867,7 @@
@@ -999,6 +1003,11 @@ {helperScript} {s3StorageConfigured} {chatInputEnabled} + oneOfLockedReason={chatInputEnabled && + arg?.type === 'static' && + keepsManagedMemory(arg.value) + ? schema.properties[argName]?.lockOneOfWhenChatEnabled + : undefined} otherArgs={Object.fromEntries( Object.entries(otherArgs).map(([key, transform]) => [ key, 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/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() } diff --git a/frontend/src/lib/components/ModulePreviewForm.svelte b/frontend/src/lib/components/ModulePreviewForm.svelte index 092eb75706..e71bf11b58 100644 --- a/frontend/src/lib/components/ModulePreviewForm.svelte +++ b/frontend/src/lib/components/ModulePreviewForm.svelte @@ -8,6 +8,7 @@ import { getContext, untrack } from 'svelte' import type { FlowEditorContext } from './flows/types' import { evalValue } from './flows/utils.svelte' + import { memoryPropertyFor } from './flows/flowInfers' import type { FlowModule } from '$lib/gen' import type { PickableProperties } from './flows/previousResults' import type SimpleEditor from './SimpleEditor.svelte' @@ -61,6 +62,9 @@ * for a surface whose form cannot open a row at all. A schema key the field registry doesn't * know is kept, so a new one is never silently dropped. */ let schemaKeys = $derived(Object.keys(schema?.properties ?? {})) + // A legacy memory kind this step still holds stays one of the options, or the one-of field would + // turn the test run's memory off. + let isAgent = $derived((mod.value as { type?: string })?.type === 'aiagent') let visibleKeys = $derived.by(() => { const all = schemaKeys @@ -71,7 +75,11 @@ for (const key of openAgentFields(openFieldsKey)) visible.add(key) for (const key of runInputKeys) visible.add(key) const known = new Set(AGENT_FIELDS.map((f) => f.key)) - return all.filter((key) => !known.has(key) || visible.has(key)) + // Listed in the agent form's order rather than the schema's, so the two read the same. + const position = new Map(AGENT_FIELDS.map((f, i) => [f.key, i])) + return all + .filter((key) => !known.has(key) || visible.has(key)) + .sort((a, b) => (position.get(a) ?? Infinity) - (position.get(b) ?? Infinity)) }) let keys: string[] = $state([]) @@ -182,7 +190,12 @@ (v) => stepsInputArgs?.setStepInputArgs(mod.id, argName, v) } type={schema.properties[argName].type} - oneOf={schema.properties[argName].oneOf} + oneOf={isAgent && argName === 'memory' + ? memoryPropertyFor( + schema.properties[argName], + stepsInputArgs?.getStepInputArgs(mod.id, argName) + )?.oneOf + : schema.properties[argName].oneOf} required={schema?.required?.includes(argName)} pattern={schema.properties[argName].pattern} bind:editor={editor[argName]} diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index 64a318acc7..41199989d2 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -21,6 +21,7 @@ type LinkedAgentDraft } from './flows/linkedAgentDrafts' import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils' + import { AGENT_HISTORY_KEYS } from './flows/agentFormFields' import { sendUserToast } from '$lib/toast' interface Props { @@ -170,11 +171,21 @@ } const agentVal = draft ? inlineAgentDraft(val, draft.args) : val - // `args` is built from the whole AI agent schema whatever the step is, so on a linked step - // it carries every brain key as undefined even though the form renders only the flow-local - // ones (`flowLocalAgentSchema`). Overlaying those would shadow the brain the draft just - // supplied with nothing, so an inlined step takes only the inputs its form actually offers. - const formKeys = draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args) + // `args` spans the whole AI agent schema, so on a linked step it carries every brain key as + // undefined; overlaying those would shadow the draft's brain, so an inlined step takes only + // the inputs its form offers. A blank history input is unset, as on the step: an expression + // evaluating to nothing reads as an empty memory id, and the step's transform is stale. + const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length) + const formKeys = ( + draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args) + ).filter( + (key) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key]) + ) + const stepTransforms = Object.fromEntries( + Object.entries((agentVal.input_transforms ?? {}) as Record).filter( + ([key]) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key]) + ) + ) // The test form only covers the schema it was given, and for a standalone agent that may be // the flow-local one (the agent editor shows the brain in its own form, not here). Take the @@ -182,9 +193,7 @@ // in the form after the test panel mounted is what runs. A linked agent needs none of this: // the server reads its brain from the resource. const inputTransforms: { [key: string]: JavascriptTransform | InputTransform } = { - ...(agentVal.agent - ? {} - : ((agentVal.input_transforms ?? {}) as Record)), + ...(agentVal.agent ? {} : stepTransforms), ...Object.fromEntries( formKeys.map((key) => [ key, 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/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index 2081e12e29..7233cb197c 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -16,6 +16,7 @@ type MenuItem = { label: string + description?: string onClick?: (e?: Event) => void href?: string icon?: any @@ -146,6 +147,7 @@ const items = typeof menuItems === 'function' ? menuItems() : menuItems return items.map((item) => ({ displayName: item.label, + description: item.description, action: item.onClick ? (e) => item.onClick?.(e) : undefined, icon: item.icon, disabled: item.disabled ?? false, diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index 8d88fe8f01..1eb0d35878 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -35,6 +35,7 @@ HistoryIcon } from 'lucide-svelte' import FlowHistory from '$lib/components/flows/FlowHistory.svelte' + import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte' import InheritedLabels from '$lib/components/InheritedLabels.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork' @@ -48,6 +49,9 @@ draft_path?: string draft_users?: { username?: string | null }[] canWrite: boolean + /** Projected from the flow value by the listing; a chat-input flow opens + * as a conversation and is badged as such. */ + chat_input_enabled?: boolean } marked: string | undefined shareModal: ShareModal @@ -124,6 +128,10 @@ {/if} +{#snippet chatBadge()} + +{/snippet} + {#snippet badges()} {#if flow.archived} diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 77434d3c59..4fb90151c1 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -67,6 +67,9 @@ badges?: import('svelte').Snippet actions?: import('svelte').Snippet customSummary?: import('svelte').Snippet + /** Rendered inline right after the title, unlike `badges`, which sit in + * their own column and are hidden below `lg`. */ + titleBadge?: import('svelte').Snippet /** Overrides the secondary path line (e.g. to strike a renamed path). * Falls back to the plain `path` string when not provided. */ pathDisplay?: import('svelte').Snippet @@ -101,6 +104,7 @@ badges, actions, customSummary, + titleBadge, pathDisplay, onSelect = () => {} }: Props = $props() @@ -275,7 +279,12 @@
{/if}
-
+
{#if customSummary} {@render customSummary?.()} {:else if marked} @@ -283,6 +292,7 @@ {:else} {!summary || summary.length == 0 ? displayPath : summary} {/if} + {@render titleBadge?.()}
{#if pathDisplay} 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/ScriptFix.svelte b/frontend/src/lib/components/copilot/ScriptFix.svelte index f586ad1aaf..78c4804cc8 100644 --- a/frontend/src/lib/components/copilot/ScriptFix.svelte +++ b/frontend/src/lib/components/copilot/ScriptFix.svelte @@ -25,7 +25,7 @@ /** The failing run's error, used when there is no job to point at. */ error?: string /** The failing run's job id. Preferred over `error`: the chat reads the - * run itself with `get_job_logs`, which gives it the logs rather than + * run itself with `get_run`, which gives it the logs rather than * just the thrown value, and keeps the composer readable. */ jobId?: string /** Set when this sits in a flow step's preview, so the session opens on diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 7de7dd32dc..0085ca701f 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,39 @@ $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' + // 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) let fileInputEl = $state(null) let folderInputEl = $state(null) let dragDepth = $state(0) @@ -373,12 +398,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) } @@ -413,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 @@ -467,12 +500,16 @@ 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. - 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,25 +534,35 @@ topLevelText.push(file) } } - if (folderEntries.length > 0) await handleAddFiles(folderEntries) - if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText) + if (folderEntries.length > 0) { + if (canLinkFolders) await handleAddFiles(folderEntries) + else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true) + } + 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 @@ -524,9 +571,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 +581,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 +591,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 +606,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 +630,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 +638,16 @@ // 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 || + attachmentsOffReason !== undefined || + showContextPicker || showAutonomyModeSelector || - (aiChatManager.mode === AIMode.SCRIPT && hasDiff)) + (chatHost.mode === AIMode.SCRIPT && hasDiff)) ) @@ -694,12 +745,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {#each pastChats as chat (chat.id)}
{:else}
- {#if aiChatManager.mode === AIMode.GLOBAL} + {#if chatHost.mode === AIMode.GLOBAL} {/if} {#if !hideModeSelector} {/if} - {#if aiChatManager.mode === AIMode.APP} + {#if chatHost.mode === AIMode.APP} {/if} - - - {#if aiChatManager.mode === AIMode.GLOBAL} + {#if chatHost.supportsModelSettings} + + + {/if} + {@render footerSettings?.()} + {#if chatHost.mode === AIMode.GLOBAL} {/if} - {#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} + {#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} {#if appContext.inspectorElement}
{/if}
- {#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled} + {#if (chatHost.mode === AIMode.NAVIGATOR || chatHost.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
{#each suggestions as suggestion (suggestion)} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 7f4514cd41..d3cc2c8bd9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -12,6 +12,8 @@ } from './context' import { AIMode } from './AIChatManager.svelte' import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext' + import { getChatViewHost } from './chatViewHost' + import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox' import { formatMention } from './mention' import { twMerge } from 'tailwind-merge' import { tick, untrack, type Snippet } from 'svelte' @@ -40,12 +42,22 @@ textByteLength, type AttachedTextFile } from './textFileUtils' + import { + fileToAttachedBlob, + matchesAccept, + MAX_ATTACHED_BLOBS, + MAX_BLOB_BYTES, + type AttachedBlob + } from './blobUtils' import { MessageDraft } from './messageDraft.svelte' import ExpandableImage, { isImageViewerOpen } from '$lib/components/common/image/ExpandableImage.svelte' - const aiChatManager = getAiChatManager() + const chatHost = getChatViewHost() + // Resolved here, not where it is used: getContext is only legal during component + // initialisation, and the mention consumer below runs inside the send gesture. + const chatManager = getAiChatManager() interface Props { availableContext: ContextElement[] @@ -65,15 +77,15 @@ showContext?: boolean bottomRightSnippet?: Snippet onKeyDown?: (e: KeyboardEvent) => void - // When provided, overrides `aiChatManager.loading` for the send/stop + // When provided, overrides `chatHost.loading` for the send/stop // button — useful for callers driving their own request lifecycle // (e.g. the inline ⌘K widget runs requests outside the global - // `aiChatManager.loading` flag). + // `chatHost.loading` flag). loading?: boolean - // Called when the user clicks Stop. Defaults to `aiChatManager.cancel()`. + // Called when the user clicks Stop. Defaults to `chatHost.cancel()`. onCancel?: () => void // Observe the composer draft as it changes (the text is local state — - // `aiChatManager.instructions` only carries programmatic prompts). Used by + // `chatHost.instructions` only carries programmatic prompts). Used by // sessions to persist the typed-but-unsent prompt with the session draft. onDraftChange?: (text: string) => void // tool_call_id of the askUserQuestion the turn is parked on, when it is. A @@ -132,7 +144,7 @@ // The composer unlocks by itself when the other tab's turn ends, so the // placeholder names what it is waiting on (the typing indicator says // where the run is). - if (aiChatManager.runHeldElsewhere) { + if (chatHost.runHeldElsewhere) { return 'Waiting for the turn in the other tab to finish' } if (pendingQuestionToolCallId !== undefined) { @@ -147,7 +159,7 @@ return placeholder } - switch (aiChatManager.mode) { + switch (chatHost.mode) { case AIMode.SCRIPT: return 'Modify this script...' case AIMode.FLOW: @@ -208,31 +220,88 @@ : undefined ) + /** + * Free slots in one attachment lane, against both that lane's own cap and any limit the + * host's consumer imposes on the turn as a whole — a flow input holding a single file + * caps images and blobs together, not one each. In-flight decodes count: two drops that + * both read the staged count before either resolves would claim the same slots twice. + */ + function attachmentSlots(laneCap: number, laneStaged: number): number { + const laneRemaining = laneCap - laneStaged + const turnCap = chatHost.maxMessageAttachments + if (turnCap === undefined) return laneRemaining + const staged = + draft.images.length + + pendingImages + + draft.files.length + + pendingFiles + + draft.blobs.length + + pendingBlobs + // A queue counts too: what is held mid-run merges into one turn on flush, so a + // second file accepted now would be dropped there instead of refused here. + const queued = + chatHost.queuedImages.length + chatHost.queuedFiles.length + chatHost.queuedBlobs.length + return Math.min(laneRemaining, Math.max(0, turnCap - staged - queued)) + } + + /** What to say when the host's own limit is the one that bit. */ + function turnCapMessage(): string { + const turnCap = chatHost.maxMessageAttachments + return turnCap === 1 + ? 'This chat sends one attachment per message.' + : `This chat sends up to ${turnCap} attachments per message.` + } + + /** Why some of what was picked did not fit, naming whichever limit actually bit. */ + function skippedMessage(laneCap: number, lane: 'images' | 'files', skipped: number): string { + return chatHost.maxMessageAttachments !== undefined + ? `${turnCapMessage()} ${skipped} file(s) were not attached.` + : `You can attach up to ${laneCap} ${lane}; ${skipped} were skipped.` + } + // Images being decoded right now. Holds off sending so a message can never go // out without an attachment the user already dropped, and reserves cap slots // against a concurrent drop. let pendingImages = $state(0) - /** Attach dropped/pasted image files (downscaled + bounded). GLOBAL mode only. */ + /** Attach dropped/pasted image files (downscaled + bounded). */ export async function addImages(files: (File | Blob)[]) { - if (aiChatManager.mode !== AIMode.GLOBAL) return + if (!chatHost.supportsMessageAttachments) return + // Attaching can be off despite the chat taking attachments — no object storage to + // upload to, say. The `+` renders disabled with the reason; a drop and a paste reach + // here instead, and would otherwise become a chip that only fails once sent. + const unavailable = chatHost.attachmentsUnavailableReason + if (unavailable) { + sendUserToast(unavailable, true) + return + } const imageFiles = files.filter(isImageFile) if (imageFiles.length === 0) return - // tryGetCurrentModel returns undefined instead of throwing: this runs from a - // drop/paste handler that can't surface a rejection. - const model = tryGetCurrentModel() - // Only known text-only models fail this, so attaching would certainly 400 the - // next turn — refuse rather than warn and send it anyway. - if (model && !modelSupportsVision(model.provider, model.model)) { - sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true) - return + // The vision check is about the model this composer's own turn will hit, so it + // only applies to a host that picks that model. Elsewhere the model is chosen + // in the flow and tryGetCurrentModel would answer for the wrong one. + if (chatHost.supportsModelSettings) { + // tryGetCurrentModel returns undefined instead of throwing: this runs from a + // drop/paste handler that can't surface a rejection. + const model = tryGetCurrentModel() + // Only known text-only models fail this, so attaching would certainly 400 the + // next turn — refuse rather than warn and send it anyway. + if (model && !modelSupportsVision(model.provider, model.model)) { + sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true) + return + } } // Count decodes already in flight: two drops that both read the image count // before either resolves would each claim the same free slots and overshoot // the cap. - const remaining = MAX_ATTACHED_IMAGES - draft.images.length - pendingImages + const remaining = attachmentSlots(MAX_ATTACHED_IMAGES, draft.images.length + pendingImages) if (remaining <= 0) { - sendUserToast(`You can attach up to ${MAX_ATTACHED_IMAGES} images.`, true) + sendUserToast( + chatHost.maxMessageAttachments !== undefined + ? turnCapMessage() + : `You can attach up to ${MAX_ATTACHED_IMAGES} images.`, + true + ) return } const oversized = imageFiles.filter((f) => f.size > MAX_IMAGE_BYTES) @@ -245,7 +314,7 @@ const batch = usable.slice(0, remaining) if (batch.length < usable.length) { sendUserToast( - `You can attach up to ${MAX_ATTACHED_IMAGES} images; ${usable.length - batch.length} were skipped.`, + skippedMessage(MAX_ATTACHED_IMAGES, 'images', usable.length - batch.length), true ) } @@ -308,17 +377,22 @@ draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes ) $effect(() => { - aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes) + chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes) }) - $effect(() => () => aiChatManager.clearComposerStaged(composerKey)) + $effect(() => () => chatHost.clearComposerStaged(composerKey)) - /** Attach dropped/picked text files (sniffed + bounded). GLOBAL mode only. */ + /** Attach dropped/picked text files (sniffed + bounded). */ export async function addTextFiles(candidates: File[]) { - if (aiChatManager.mode !== AIMode.GLOBAL) return + if (!chatHost.supportsMessageAttachments) return if (candidates.length === 0) return - const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles + const remaining = attachmentSlots(MAX_ATTACHED_FILES, draft.files.length + pendingFiles) if (remaining <= 0) { - sendUserToast(`You can attach up to ${MAX_ATTACHED_FILES} files.`, true) + sendUserToast( + chatHost.maxMessageAttachments !== undefined + ? turnCapMessage() + : `You can attach up to ${MAX_ATTACHED_FILES} files.`, + true + ) return } const oversized = candidates.filter((f) => f.size > MAX_TEXT_FILE_BYTES) @@ -333,10 +407,7 @@ if (usable.length === 0) return let batch = usable.slice(0, remaining) if (batch.length < usable.length) { - sendUserToast( - `You can attach up to ${MAX_ATTACHED_FILES} files; ${usable.length - batch.length} were skipped.`, - true - ) + sendUserToast(skippedMessage(MAX_ATTACHED_FILES, 'files', usable.length - batch.length), true) } // Conversation-level byte budget: transcript + queue + every live // composer's stage (this one and, mid-edit, the other) + this composer's @@ -346,7 +417,7 @@ // stage stands in for it, so counting both would charge those bytes twice. let budget = MAX_CONVERSATION_FILE_BYTES - - aiChatManager.attachmentBytesExcluding(composerKey) - + chatHost.attachmentBytesExcluding(composerKey) - draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) - pendingFileBytes const withinBudget: File[] = [] @@ -388,7 +459,7 @@ // from the budget — the decoded sizes replace it. const liveBudget = MAX_CONVERSATION_FILE_BYTES - - aiChatManager.attachmentBytesExcluding(composerKey) - + chatHost.attachmentBytesExcluding(composerKey) - draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) - (pendingFileBytes - reservedBytes) const { droppedAtBudget } = draft.addFiles(reads, liveBudget) @@ -410,6 +481,82 @@ draft.files = draft.files.filter((_, i) => i !== index) } + // Blobs being read right now — same send-hold/slot-reservation role as pendingImages. + let pendingBlobs = $state(0) + + /** + * Attach non-image files through the lane the host reads: text for a host that decodes + * them, blobs for one that forwards them verbatim. The picker, a drop and both pastes all + * route here, and `accept` is re-applied since drops and pastes bypass the picker's filter. + */ + export async function addNonImageFiles(files: File[]) { + if (files.length === 0) return + // Same reason as in addImages. + const unavailable = chatHost.attachmentsUnavailableReason + if (unavailable) { + sendUserToast(unavailable, true) + return + } + if (!chatHost.attachmentsAsBlobs) { + await addTextFiles(files) + return + } + const allowed = files.filter((f) => matchesAccept(f, chatHost.attachmentAccept)) + if (allowed.length < files.length) { + sendUserToast( + `${files.length - allowed.length} file(s) skipped — this chat accepts ${chatHost.attachmentAccept}.`, + true + ) + } + await addBlobs(allowed) + } + + /** Attach files the host takes verbatim (a PDF, say). Kept out of addTextFiles: + * that one decodes to a string and drops anything the binary sniff rejects. */ + export async function addBlobs(candidates: File[]) { + if (!chatHost.supportsMessageAttachments) return + if (candidates.length === 0) return + const oversized = candidates.filter((f) => f.size > MAX_BLOB_BYTES) + if (oversized.length > 0) { + const mb = Math.round(MAX_BLOB_BYTES / 1_000_000) + sendUserToast(`${oversized.length} file(s) over ${mb}MB were skipped.`, true) + } + const usable = candidates.filter((f) => f.size <= MAX_BLOB_BYTES) + if (usable.length === 0) return + const remaining = attachmentSlots(MAX_ATTACHED_BLOBS, draft.blobs.length + pendingBlobs) + if (remaining <= 0) { + sendUserToast( + chatHost.maxMessageAttachments !== undefined + ? turnCapMessage() + : `You can attach up to ${MAX_ATTACHED_BLOBS} files.`, + true + ) + return + } + const batch = usable.slice(0, remaining) + if (batch.length < usable.length) { + sendUserToast(skippedMessage(MAX_ATTACHED_BLOBS, 'files', usable.length - batch.length), true) + } + pendingBlobs += batch.length + try { + const added: AttachedBlob[] = [] + for (const file of batch) { + try { + added.push(await fileToAttachedBlob(file)) + } catch (e) { + sendUserToast(`Could not read ${file.name}`, true) + } + } + if (added.length > 0) draft.addBlobs(added) + } finally { + pendingBlobs -= batch.length + } + } + + function removeBlob(index: number) { + draft.blobs = draft.blobs.filter((_, i) => i !== index) + } + // App mode @ mention state let showAppContextTooltip = $state(false) let appContextTooltipWord = $state('') @@ -420,9 +567,9 @@ // Modes that show the rich textarea with @-context support (workspace // scripts, workspace flows, code blocks, DBs, etc.). const isContextEnabledMode = $derived( - aiChatManager.mode === AIMode.SCRIPT || - aiChatManager.mode === AIMode.FLOW || - aiChatManager.mode === AIMode.GLOBAL + chatHost.mode === AIMode.SCRIPT || + chatHost.mode === AIMode.FLOW || + chatHost.mode === AIMode.GLOBAL ) const domSelectorChips = $derived( @@ -491,7 +638,8 @@ // Attachments still decoding/reading (or mid-drop-routing) count as // occupancy too — they belong to a draft the user started even though // their lane is still empty. - if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) return false + if (pendingImages > 0 || pendingFiles > 0 || pendingBlobs > 0 || ingestionHolds > 0) + return false if ( !draft.replaceIfEmpty({ text: value, @@ -514,15 +662,17 @@ export function prependText( text: string, restoredImages: AttachedImage[] = [], - restoredFiles: AttachedTextFile[] = [] + restoredFiles: AttachedTextFile[] = [], + restoredBlobs: AttachedBlob[] = [] ): boolean { // mergedIntoDraft: the restored text landed on top of a draft the user was // already writing — both instructions now share one composer, so the caller // must keep both their contexts rather than replacing one with the other. - const { mergedIntoDraft, droppedImages, droppedFiles } = draft.prepend({ + const { mergedIntoDraft, droppedImages, droppedFiles, droppedBlobs } = draft.prepend({ text, images: restoredImages, - files: restoredFiles + files: restoredFiles, + blobs: restoredBlobs }) if (droppedImages > 0) { sendUserToast( @@ -536,6 +686,12 @@ true ) } + if (droppedBlobs > 0) { + sendUserToast( + `You can attach up to ${MAX_ATTACHED_BLOBS} files; ${droppedBlobs} restored file(s) were dropped.`, + true + ) + } focusInput() return mergedIntoDraft } @@ -551,14 +707,14 @@ * the composer. The conversation is left untouched — resending creates a new * message, unlike the bubble's edit pencil which rewinds the conversation. */ function recallLastSentMessage(): boolean { - const messages = aiChatManager.displayMessages + const messages = chatHost.displayMessages for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message.role !== 'user' || message.synthetic) continue // Images come from the stored turn, never the bubble: a provider // rejection strips them from history while the bubble keeps its copy, // and recalling that copy would re-attach the refused image. - const images = aiChatManager.storedImages(i) ?? [] + const images = chatHost.storedImages(i) ?? [] // Eligibility looks at the bubble, though: the last thing the user // actually sent is the recall boundary, so a context-only turn (GLOBAL // allows text-free sends with chips) recalls its chips, and a turn @@ -582,8 +738,7 @@ // count against the conversation budget — re-admit them instead of // copying, or resending would blow past MAX_CONVERSATION_FILE_BYTES. if (message.files?.length) { - const budget = - MAX_CONVERSATION_FILE_BYTES - aiChatManager.attachmentBytesExcluding(composerKey) + const budget = MAX_CONVERSATION_FILE_BYTES - chatHost.attachmentBytesExcluding(composerKey) const { droppedAtBudget } = draft.addFiles(message.files, budget) if (droppedAtBudget > 0) { const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000) @@ -654,10 +809,10 @@ if ( contextElement.type === 'app_datatable' && - aiChatManager.mode === AIMode.APP && - aiChatManager.appAiChatHelpers + chatHost.mode === AIMode.APP && + chatHost.appAiChatHelpers ) { - const appAiChatHelpers = aiChatManager.appAiChatHelpers + const appAiChatHelpers = chatHost.appAiChatHelpers appAiChatHelpers.addTableToWhitelist( contextElement.datatableName, contextElement.schemaName, @@ -699,14 +854,21 @@ * consuming past them would hand this message a mention the user picked for * the next one. */ function consumeMentionsIfGlobal() { - if (aiChatManager.mode !== AIMode.GLOBAL) return - aiChatManager.contextManager?.consumeMentionContext() + if (chatHost.mode !== AIMode.GLOBAL) return + // The mention context belongs to the copilot's own ContextManager, which only + // the manager has — the GLOBAL guard above means this host is always it. + chatManager.contextManager?.consumeMentionContext() } function sendRequest() { // The send button is disabled while decoding, but Enter reaches here directly. // Sending now would drop the in-flight attachments onto the following message. - if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) { + if (pendingImages > 0 || pendingFiles > 0 || pendingBlobs > 0 || ingestionHolds > 0) { + return + } + // A host whose consumer needs a message of its own refuses an attachment-only + // turn. Returning before `take()` keeps the chips where the user put them. + if (chatHost.requiresMessageText && draft.text.trim() === '') { return } // Read before `take()` empties the draft the id derives from, and only take @@ -715,7 +877,7 @@ const answeredQuestionId = questionAnsweredBySend if ( answeredQuestionId && - aiChatManager.handleUserQuestionAnswer(answeredQuestionId, [ + chatHost.handleUserQuestionAnswer(answeredQuestionId, [ expanded(chatDraft(draft.text.trim(), draft.pastes)) ]) ) { @@ -727,7 +889,7 @@ contextTextareaComponent?.clearForSend() return } - if (aiChatManager.loading) { + if (chatHost.loading) { // Queue the message instead of silently discarding it — it is // auto-sent when the streaming turn completes successfully. // Editing-while-loading keeps the old discard behavior. Paste @@ -738,14 +900,15 @@ // chips picked at press time. if ( editingMessageIndex === null && - (!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0)) + (!draft.isEmpty || (chatHost.mode === AIMode.GLOBAL && selectedContext.length > 0)) ) { const sent = draft.take() - aiChatManager.queueMessage( + chatHost.queueMessage( expanded(chatDraft(sent.text, sent.pastes)), sent.images, [...selectedContext], - sent.files + sent.files, + sent.blobs ) // Consumed at enqueue, not at flush: the entry above pinned them. consumeMentionsIfGlobal() @@ -758,7 +921,7 @@ // message's original chips), so send exactly what's shown — the user may // have added or removed chips. const sent = draft.take() - aiChatManager.restartGeneration( + chatHost.restartGeneration( editingMessageIndex, sent.text, sent.pastes, @@ -771,13 +934,17 @@ const sent = draft.take() // Pin before consuming: the manager falls back to the live selection only // when given no override, and the consume below empties it. - const carried = aiChatManager.mode === AIMode.GLOBAL ? [...selectedContext] : undefined + const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined consumeMentionsIfGlobal() - aiChatManager.sendRequest({ + // A host that refuses the turn puts the draft back itself (see AIChatManager's + // restoreToInput and FlowChatViewHost's upload failure): restoring here too + // would double the text and every attachment. + chatHost.sendRequest({ instructions: sent.text, pastes: sent.pastes, images: sent.images, files: sent.files, + blobs: sent.blobs, contextOverride: carried, contextOverrideOrigin: carried ? 'pinned' : undefined }) @@ -999,37 +1166,64 @@ updateAppTooltipPosition(appTooltipCurrentViewNumber) } }) + + /** + * Clipboard files on the plain composer, as ContextTextarea does for the rich one. Only + * when the clipboard has no text: a spreadsheet copy carries a bitmap next to the text, + * and pasting a cell range must paste the cells. + */ + function handlePlainPaste(e: ClipboardEvent) { + if (!chatHost.supportsMessageAttachments) return + if ((e.clipboardData?.getData('text/plain') ?? '').trim()) return + const pasted = Array.from(e.clipboardData?.files ?? []) + const images = pasted.filter((f) => f.type.startsWith('image/')) + const others = pasted.filter((f) => !f.type.startsWith('image/')) + if (images.length === 0 && others.length === 0) return + e.preventDefault() + if (images.length > 0) void addImages(images) + if (others.length > 0) void addNonImageFiles(others) + } {#snippet sendStopButton()} - {@const isLoading = (loading ?? aiChatManager.loading) && !questionAnsweredBySend} + {@const isLoading = (loading ?? chatHost.loading) && !questionAnsweredBySend} {@const emptyDraft = draft.isEmpty} + {@const needsText = chatHost.requiresMessageText && draft.text.trim() === ''} + + {@const needsTextForAttachment = needsText && !emptyDraft} {@const sendDisabled = disabled || pendingImages > 0 || pendingFiles > 0 || + pendingBlobs > 0 || ingestionHolds > 0 || + needsText || (emptyDraft && (onSendRequest !== undefined || - aiChatManager.mode !== AIMode.GLOBAL || + chatHost.mode !== AIMode.GLOBAL || selectedContext.length === 0))} 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/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.queuedBlobs.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 || chatHost.queuedBlobs.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 +97,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/blobUtils.test.ts b/frontend/src/lib/components/copilot/chat/blobUtils.test.ts new file mode 100644 index 0000000000..fed92e9e4c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/blobUtils.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { matchesAccept } from './blobUtils' + +function file(name: string, type: string): File { + return new File(['x'], name, { type }) +} + +describe('matchesAccept', () => { + it('matches an extension, a type wildcard and an exact media type', () => { + expect(matchesAccept(file('report.PDF', ''), '.pdf')).toBe(true) + expect(matchesAccept(file('shot.png', 'image/png'), 'image/*')).toBe(true) + expect(matchesAccept(file('shot.png', 'image/png'), 'image/png')).toBe(true) + }) + + it('refuses a file no pattern covers, and allows everything when the list is empty', () => { + expect(matchesAccept(file('notes.txt', 'text/plain'), '.pdf, image/*')).toBe(false) + expect(matchesAccept(file('notes.txt', 'text/plain'), '')).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/blobUtils.ts b/frontend/src/lib/components/copilot/chat/blobUtils.ts new file mode 100644 index 0000000000..41b5c76183 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/blobUtils.ts @@ -0,0 +1,58 @@ +/** + * Message attachments kept as their original bytes, such as a PDF, for a host that + * forwards them to object storage. Images are re-encoded and text files decoded instead, + * so neither lane can carry what the user picked unchanged. + */ + +/** Blobs one message may carry — the same slot cap images and text files use. */ +export const MAX_ATTACHED_BLOBS = 8 + +/** + * Per-blob byte cap. The data URL sits in composer state until send, so this + * bounds what one message can hold in memory; a host uploading elsewhere pays + * the same bytes again on the wire. + */ +export const MAX_BLOB_BYTES = 20_000_000 + +export type AttachedBlob = { + name: string + /** The file's own media type, verbatim — the upload's Content-Type depends on it. */ + mediaType: string + /** `data:;base64,<...>` of the original bytes. */ + dataUrl: string + size: number +} + +/** + * Whether a file satisfies an `accept` list — the same list the OS picker gets, applied + * again on drop, where the browser enforces nothing. + */ +export function matchesAccept(file: File, accept: string): boolean { + const patterns = accept + .split(',') + .map((p) => p.trim().toLowerCase()) + .filter(Boolean) + if (patterns.length === 0) return true + const type = file.type.toLowerCase() + const name = file.name.toLowerCase() + return patterns.some((pattern) => { + if (pattern.startsWith('.')) return name.endsWith(pattern) + if (pattern.endsWith('/*')) return type.startsWith(pattern.slice(0, -1)) + return type === pattern + }) +} + +export async function fileToAttachedBlob(file: File): Promise { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result)) + reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name}`)) + reader.readAsDataURL(file) + }) + return { + name: file.name, + mediaType: file.type || 'application/octet-stream', + dataUrl, + size: file.size + } +} 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..4af23337e2 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -0,0 +1,173 @@ +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 { AttachedBlob } from './blobUtils' +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[] + blobs?: AttachedBlob[] + /** 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[] + readonly queuedBlobs: AttachedBlob[] + queueMessage: ( + text: string, + images?: AttachedImage[], + context?: ContextElement[], + files?: AttachedTextFile[], + blobs?: AttachedBlob[] + ) => 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. Attachments ride + * one message; where they go afterwards is the host's business (see sendRequest). */ + supportsMessageAttachments: boolean + /** + * Why attaching is off right now, when the host would otherwise take attachments. Distinct + * from `supportsMessageAttachments` being false, which means this chat never takes them: + * here the composer keeps the control and says what is missing, because moving the input + * elsewhere would only offer an editor that cannot work either. + */ + attachmentsUnavailableReason?: string + /** 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, and the drop filter. A host whose consumer only + * understands some formats narrows it so the rest are refused rather than ignored. */ + attachmentAccept: string + /** How many attachments one turn can carry, when the consumer holds a fixed number — + * a flow input that is a single file, say. Undefined means no limit. Enforced at the + * picker and on drop, so what the composer shows is what the turn actually sends. */ + maxMessageAttachments?: number + /** Take non-image attachments verbatim (`blobs`) instead of decoding them to text. + * True where the bytes are forwarded somewhere — object storage — rather than read + * in the browser. */ + attachmentsAsBlobs: boolean + 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/ducklakeTools.test.ts b/frontend/src/lib/components/copilot/chat/ducklakeTools.test.ts index fdd8c74558..d02d1b9497 100644 --- a/frontend/src/lib/components/copilot/chat/ducklakeTools.test.ts +++ b/frontend/src/lib/components/copilot/chat/ducklakeTools.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { listMock } = vi.hoisted(() => ({ listMock: vi.fn() })) +const { listMock, listMetricsMock } = vi.hoisted(() => ({ + listMock: vi.fn(), + listMetricsMock: vi.fn() +})) vi.mock('./shared', () => ({ createToolDef: (_schema: unknown, name: string, description: string) => ({ @@ -10,7 +13,8 @@ vi.mock('./shared', () => ({ })) vi.mock('$lib/gen', () => ({ - WorkspaceService: { listDucklakes: listMock } + WorkspaceService: { listDucklakes: listMock }, + DataMetricService: { listDataMetrics: listMetricsMock } })) import { getDucklakeTools } from './ducklakeTools' @@ -31,7 +35,10 @@ function run(name: string, args: Record = {}) { }) } -beforeEach(() => listMock.mockReset()) +beforeEach(() => { + listMock.mockReset() + listMetricsMock.mockReset() +}) describe('list_ducklakes', () => { it('returns the configured catalog names', async () => { @@ -51,3 +58,65 @@ describe('list_ducklakes', () => { expect(result).toContain('still draft the pipeline scripts') }) }) + +describe('list_data_metrics', () => { + it('forwards the filters and returns the declarations', async () => { + listMetricsMock.mockResolvedValue({ + metrics: [ + { + script_path: 'f/analytics/rev', + table_path: 'main/main.orders', + kind: 'measure', + name: 'revenue', + expr: 'sum(amount)', + filter: 'not is_test' + } + ] + }) + const result = await run('list_data_metrics', { + table: 'ducklake://main/main.orders', + path_prefix: 'f/analytics', + limit: 50 + }) + expect(listMetricsMock).toHaveBeenCalledWith({ + workspace: 'test-workspace', + table: 'ducklake://main/main.orders', + pathPrefix: 'f/analytics', + perPage: 50 + }) + expect(JSON.parse(result).metrics[0]).toMatchObject({ name: 'revenue', expr: 'sum(amount)' }) + }) + + it('never reports an empty result as proof that nothing is declared', async () => { + listMetricsMock.mockResolvedValue({ metrics: [] }) + const result = await run('list_data_metrics', {}) + // Unreadable declarations are omitted, not flagged, so absence is unprovable. + expect(result).toContain('does not establish') + expect(result).toContain('cannot read') + }) + + // The server matches a lake-less name against nothing, so the readability hedge + // would confirm "nothing is declared" for a filter worth retrying. The scheme is + // optional on the way in, so the retry it names must not carry it back. + it.each(['orders', 'ducklake://orders'])( + 'blames the missing lake, not readability, for table %s', + async (table) => { + listMetricsMock.mockResolvedValue({ metrics: [] }) + const result = await run('list_data_metrics', { table }) + expect(result).toContain('`/orders`') + expect(result).not.toContain('cannot read') + } + ) + + it('warns that more declarations exist when the page is cut short', async () => { + listMetricsMock.mockResolvedValue({ + // A cursor only comes back on a full page, never with an empty one. + metrics: [{ table_path: 't', kind: 'measure', name: 'n', script_path: 's' }], + next_cursor: { table_path: 't', kind: 'measure', name: 'n', script_path: 's' } + }) + const result = await run('list_data_metrics', {}) + // Without this the model reads a partial page as "no such measure" and + // re-derives a number that disagrees with the declared one. + expect(result).toContain('rather than concluding a measure is undeclared') + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/ducklakeTools.ts b/frontend/src/lib/components/copilot/chat/ducklakeTools.ts index 5594e4f3d0..dfadc58db9 100644 --- a/frontend/src/lib/components/copilot/chat/ducklakeTools.ts +++ b/frontend/src/lib/components/copilot/chat/ducklakeTools.ts @@ -1,17 +1,19 @@ import { z } from 'zod' -import { WorkspaceService } from '$lib/gen' +import { DataMetricService, WorkspaceService } from '$lib/gen' import { createToolDef, type Tool } from './shared' /** - * Workspace-scoped DuckLake readiness tool, the pipeline counterpart to - * `list_datatables` in `datatableTools.ts`. + * Workspace-scoped DuckLake tools, the pipeline counterpart to `list_datatables` + * in `datatableTools.ts`. * * A data pipeline materializes DuckLake tables and reads/writes S3 assets, which * only work once the workspace has object storage + a DuckLake catalog - * configured. This tool lets the chat detect that prerequisite (and warn with - * role-appropriate next steps) instead of silently producing a pipeline that - * cannot run. It is a plain read gated only by workspace membership, so it needs - * no app context and belongs in the global tool set. + * configured. `list_ducklakes` lets the chat detect that prerequisite (and warn + * with role-appropriate next steps) instead of silently producing a pipeline that + * cannot run. `list_data_metrics` reads the declarations recorded against those + * lake tables, not the tables themselves. Both are plain reads gated only by + * workspace membership, so they need no app context and belong in the global tool + * set. */ /** List the names of the DuckLake catalogs configured in the workspace. */ @@ -31,9 +33,78 @@ const listDucklakesToolDef = createToolDef( 'List the DuckLake catalogs configured in this workspace, by name. Call this before building or deploying a data pipeline that materializes DuckLake tables or reads/writes S3 assets: if it returns none, the workspace has no object storage + DuckLake configured and the pipeline cannot run until a workspace admin sets it up. Returns names only.' ) +const listDataMetricsSchema = z.object({ + table: z + .string() + .optional() + .describe( + 'Only declarations on this DuckLake table, as `/` or `/.
`, with or without the `ducklake://` scheme. A name with no lake matches nothing and comes back empty.' + ), + path_prefix: z + .string() + .optional() + .describe('Only declarations made by scripts under this path, e.g. `f/analytics`.'), + limit: z + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe('Max number of declarations to return. Defaults to 200.') +}) +const listDataMetricsToolDef = createToolDef( + listDataMetricsSchema, + 'list_data_metrics', + 'List the measures and dimensions declared on DuckLake tables (from `// measure` / `// dimension` annotations in deployed scripts). Call this before writing any aggregate query over a DuckLake table: a declared measure is the canonical definition of that number, and reproducing it yourself silently disagrees with it (a `revenue` measure typically excludes refunds or test rows). Use each returned `expr` verbatim, and when a measure has a `filter` write it as `expr FILTER (WHERE filter)` so measures with different predicates share one GROUP BY. Only declarations whose producing script you can read are returned, so what comes back is never proof of what exists: if the number you need is not here you may still write your own aggregate, but say that you found no declared measure for it rather than implying none exists.' +) + +// The endpoint drops declarations whose producing script the caller cannot read +// (token scope + RLS on `script`), so an empty result means "none declared" or +// "none readable by you" and the tool cannot tell which. +const NO_DATA_METRICS_NOTE = + 'Nothing matched. That does not establish that nothing is declared: declarations whose producing script you cannot read are omitted from this list, not flagged. You may write your own aggregate, but tell the user you found no declared measure you can read rather than stating none is declared.' + +// Well under the endpoint's 1000 cap: a full page is pretty-printed into the +// chat context, and 1000 declarations would cost tens of thousands of tokens. +const DEFAULT_DATA_METRICS_LIMIT = 200 + /** The workspace DuckLake tools, for registration in global mode. */ export function getDucklakeTools(): Tool<{}>[] { return [ + { + def: listDataMetricsToolDef, + planModeSafe: true, + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = listDataMetricsSchema.parse(args) + toolCallbacks.setToolStatus(toolId, { content: 'Listing declared measures...' }) + const limit = parsed.limit ?? DEFAULT_DATA_METRICS_LIMIT + const { metrics, next_cursor } = await DataMetricService.listDataMetrics({ + workspace, + table: parsed.table, + pathPrefix: parsed.path_prefix, + perPage: limit + }) + // `canonical_table_path` expands a value only once it contains a `/`, so a bare + // table name matches nothing — a retryable filter, not a permissions outcome. + const bareTable = parsed.table?.replace('ducklake://', '') + const emptyNote = + bareTable && !bareTable.includes('/') + ? `Nothing matched \`${parsed.table}\`: a table filter must name its lake, as \`/${bareTable}\`. Re-call with the lake before concluding anything about what is declared.` + : NO_DATA_METRICS_NOTE + const note = next_cursor + ? `More declarations exist beyond the first ${limit}. Re-call with table/path_prefix to target what you are looking for, or with a higher limit (max 1000) for the rest of the list, rather than concluding a measure is undeclared.` + : metrics.length === 0 + ? emptyNote + : undefined + const result = JSON.stringify({ metrics, ...(note ? { note } : {}) }, null, 2) + toolCallbacks.setToolStatus(toolId, { + content: `Listed ${metrics.length} declared measure(s)/dimension(s)`, + result + }) + return result + } + }, { def: listDucklakesToolDef, planModeSafe: true, 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/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index 03b839dd98..b8f44d2635 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlow.json +++ b/frontend/src/lib/components/copilot/chat/flow/openFlow.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"version":"1.811.1","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"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 — 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"}},"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"}},"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"}},"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"}},"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"}},"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",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"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"}]},"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 — 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 +{"openapi":"3.0.3","info":{"version":"1.813.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"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 — 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"}},"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"}},"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"}},"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"}},"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"}},"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",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"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"}]},"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 — 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/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts index 667a597b57..16a249e5ac 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts @@ -1,6 +1,6 @@ import { z } from "zod" -export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -17,10 +17,10 @@ export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "i message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") -export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("Automatic context management"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Explicit message history")]).describe("Conversation memory configuration"), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -37,7 +37,7 @@ export const flowModuleSchema = z.object({ "id": z.string().describe("Unique ide message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Initial delay in seconds").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff (delay doubles each time)").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") export const flowModulesSchema = z.array(flowModuleSchema) diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts index 0b1ea0952e..06109708de 100644 --- a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts @@ -31,6 +31,36 @@ const CATALOG = [ properties: { page: { type: 'integer' }, per_page: { type: 'integer' } } } }, + { + name: 'listDataMetrics', + description: 'List declared measures and dimensions', + instructions: '', + path: '/w/{workspace}/data_metrics/list', + method: 'GET' + }, + { + name: 'listQueue', + description: 'List queued jobs', + instructions: 'List the jobs waiting in the queue', + path: '/w/{workspace}/jobs/queue/list', + method: 'GET', + query_params_schema: { + type: 'object', + properties: { running: { type: 'boolean' }, per_page: { type: 'integer' } } + } + }, + { + name: 'getJobUpdates', + description: 'Get job updates', + instructions: '', + path: '/w/{workspace}/jobs_u/getupdate/{id}', + method: 'GET', + path_params_schema: { + type: 'object', + properties: { workspace: { type: 'string' }, id: { type: 'string' } }, + required: ['workspace', 'id'] + } + }, { name: 'getJob', description: 'Get job details', @@ -165,11 +195,12 @@ beforeEach(() => { describe('search_api_endpoints', () => { it('matches on name/path tokens, plural-insensitively, and excludes covered endpoints', async () => { - const result = await run('search_api_endpoints', { query: 'worker' }) - expect(result.matches.map((m: any) => m.name)).toEqual(['listWorkers']) - expect(result.matches[0].endpoint).toBe('GET /workers/list') - expect(result.matches[0].params).toEqual(['page', 'per_page']) - expect(result.matches[0].instructions).toContain('ping status') + // Singular "job" matches the plural "jobs" path segment. + const result = await run('search_api_endpoints', { query: 'list queued job' }) + expect(result.matches[0].name).toBe('listQueue') + expect(result.matches[0].endpoint).toBe('GET /w/{workspace}/jobs/queue/list') + expect(result.matches[0].params).toEqual(['running', 'per_page']) + expect(result.matches[0].instructions).toContain('waiting in the queue') const flows = await run('search_api_endpoints', { query: 'create flow' }) expect(flows.matches.map((m: any) => m.name)).not.toContain('createFlow') @@ -179,8 +210,11 @@ describe('search_api_endpoints', () => { it('returns endpoint categories when nothing matches', async () => { const result = await run('search_api_endpoints', { query: 'kubernetes' }) expect(result.matches).toEqual([]) - expect(result.hint).toContain('workers') - expect(result.hint).toContain('jobs') + expect(result.hint).toContain('jobs_u') + // Categories are built from the uncovered endpoints only, so a covered one + // must not be advertised as somewhere to retry. + expect(result.hint).not.toContain('workers') + expect(result.hint).not.toContain('data_metrics') }) }) @@ -195,6 +229,9 @@ describe('call_api_get', () => { const mutating = await run('call_api_get', { name: 'cancelQueuedJob' }) expect(mutating.error).toContain('call_api_endpoint') + const job = await run('call_api_get', { name: 'getJob' }) + expect(job.error).toContain('get_run') + const deleting = await run('call_api_endpoint', { name: 'deleteSchedule' }) expect(deleting.error).toContain('delete_workspace_item') @@ -238,6 +275,21 @@ describe('call_api_get', () => { expect(search.matches.map((m: any) => m.name)).not.toContain('getScriptByPath') }) + it('refuses the worker and data-metric reads, pointing at their dedicated tools', async () => { + for (const [name, tool, query] of [ + ['listWorkers', 'list_workers', 'workers'], + ['listDataMetrics', 'list_data_metrics', 'data metrics'] + ]) { + const result = await run('call_api_get', { name }) + expect(result.success).toBe(false) + expect(result.error).toContain(tool) + + const search = await run('search_api_endpoints', { query }) + expect(search.matches.map((m: any) => m.name)).not.toContain(name) + expect(search.covered_by_dedicated_tools?.join(' ')).toContain(tool) + } + }) + it('refuses variable reads so variable values never reach the model', async () => { const result = await run('call_api_get', { name: 'getVariable' }) expect(result.success).toBe(false) @@ -248,7 +300,7 @@ describe('call_api_get', () => { }) it('returns the endpoint schema when a required path param is missing', async () => { - const result = await run('call_api_get', { name: 'getJob' }) + const result = await run('call_api_get', { name: 'getJobUpdates' }) expect(result.success).toBe(false) expect(result.error).toContain('id') expect(result.schema.path_params_schema.required).toContain('id') @@ -258,12 +310,14 @@ describe('call_api_get', () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), - json: async () => [{ worker: 'w1' }] + json: async () => [{ id: 'job-1' }] }) vi.stubGlobal('fetch', fetchMock) - const result = await run('call_api_get', { name: 'listWorkers', params: { page: 2 } }) - expect(fetchMock).toHaveBeenCalledWith('/api/workers/list?page=2', { method: 'GET' }) - expect(result).toEqual({ success: true, data: [{ worker: 'w1' }] }) + const result = await run('call_api_get', { name: 'listQueue', params: { running: true } }) + expect(fetchMock).toHaveBeenCalledWith('/api/w/test-ws/jobs/queue/list?running=true', { + method: 'GET' + }) + expect(result).toEqual({ success: true, data: [{ id: 'job-1' }] }) }) }) @@ -290,7 +344,7 @@ describe('call_api_endpoint', () => { }) it('redirects GET endpoints to call_api_get', async () => { - const result = await run('call_api_endpoint', { name: 'listWorkers' }) + const result = await run('call_api_endpoint', { name: 'listQueue' }) expect(result.error).toContain('call_api_get') }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts index 42d45916eb..42c68c6ebf 100644 --- a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts @@ -58,7 +58,10 @@ const COVERED_ENDPOINTS: Record = { searchDocs: 'search_docs', readDocsPage: 'read_docs_page', listJobs: 'list_runs', - getJobLogs: 'get_job_logs', + listWorkers: 'list_workers', + listDataMetrics: 'list_data_metrics', + getJob: 'get_run', + getJobLogs: 'get_run', runScriptPreviewAndWaitResult: 'test_run_script' } @@ -230,7 +233,7 @@ const searchApiEndpointsSchema = z.object({ query: z .string() .describe( - 'Keywords matched against endpoint names, paths, and descriptions (e.g. "workers", "queue", "run flow"). Jobs are called "runs" in the UI.' + 'Keywords matched against endpoint names, paths, and descriptions (e.g. "queue", "run flow", "audit log"). Jobs are called "runs" in the UI.' ) }) @@ -263,7 +266,7 @@ export const apiCatalogTools: Tool<{}>[] = [ def: createToolDef( searchApiEndpointsSchema, 'search_api_endpoints', - 'Search the Windmill REST API endpoint catalog for operations no dedicated tool covers (workers, queue state, job details, running deployed items, deletions, ...). Returns endpoint names to pass to call_api_get or call_api_endpoint.' + 'Search the Windmill REST API endpoint catalog for operations no dedicated tool covers (queue state, job details, running deployed items, deletions, ...). Returns endpoint names to pass to call_api_get or call_api_endpoint.' ), planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { 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 051304cafa..825fdd34c3 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -85,10 +85,33 @@ vi.mock('$lib/gen', async () => { runScriptByPath: vi.fn(async () => 'job-script-by-path'), getJob: vi.fn(async () => ({ type: 'CompletedJob', + id: 'job-123', + job_kind: 'script', + script_path: 'f/team/runner', success: true, + canceled: false, + args: { n: 3 }, result: { ok: true }, logs: 'test logs' })), + getJobArgs: vi.fn(async () => ({ big: 'real args' })), + getCompletedJobResultMaybe: vi.fn(async () => ({ completed: true, result: { big: 'x' } })), + getFlowAllResults: vi.fn(async () => ({ + entries: [ + { + job_id: 'job-123', + label: 'Flow', + kind: 'script', + depth: 0, + sibling_index: 1, + sibling_count: 1, + status: 'success', + success: true + } + ], + truncated: false, + scope_filtered: false + })), // What every job wait polls first; unmocked it reaches the real client and the // wait never returns. Answers completed, so one tick settles the job. getJobUpdates: vi.fn(async () => ({ @@ -233,6 +256,9 @@ vi.mock('$lib/gen', async () => { createVariable: vi.fn(async () => 'created'), updateVariable: vi.fn(async () => 'updated') }), + WorkerService: wrapService(actual.WorkerService, { + listWorkers: vi.fn(async () => []) + }), FolderService: wrapService(actual.FolderService, { createFolder: vi.fn(async () => 'created') }), @@ -241,6 +267,17 @@ vi.mock('$lib/gen', async () => { const user = whoamiByWorkspace.get(workspace) if (!user) throw new Error(`not a member of ${workspace}`) return user + }), + // `refreshSuperadmin` cancels the previous in-flight call, so this stands in for + // the CancelablePromise the real client returns. + globalWhoami: vi.fn(() => { + const pending: any = Promise.resolve({ + email: 'devops@windmill.dev', + super_admin: false, + devops: true + }) + pending.cancel = () => {} + return pending }) }), DraftService: wrapService(actual.DraftService, { @@ -366,9 +403,10 @@ import { ScheduleService, ScriptService, UserService, - VariableService + VariableService, + WorkerService } from '$lib/gen' -import { superadmin, userStore, usersWorkspaceStore } from '$lib/stores' +import { devopsRole, superadmin, userStore, usersWorkspaceStore } from '$lib/stores' import { processSecretArgs } from '$lib/components/secretArgUtils' import { clearWorkspaceRoleCache } from '$lib/user' import { get } from 'svelte/store' @@ -489,7 +527,7 @@ describe('global AI tools', () => { expect(names).toContain('test_run_script') expect(names).toContain('test_run_flow') expect(names).toContain('test_run_step') - expect(names).toContain('get_job_logs') + expect(names).toContain('get_run') expect(names).toContain('list_runs') }) @@ -715,29 +753,306 @@ describe('global AI tools', () => { ) }) - it('fetches job logs by id and always suppresses the backend ansi hint line', async () => { - const result = await callGlobalTool('get_job_logs', { id: 'job-123' }) + it('lists workers with the diagnostic fields only', async () => { + vi.mocked(WorkerService.listWorkers).mockResolvedValueOnce([ + { + worker: 'wk-1', + worker_instance: 'host-1', + worker_group: 'gpu', + custom_tags: ['gpu'], + last_ping: 3, + jobs_executed: 12, + started_at: '2024-01-01T00:00:00Z', + ip: '10.0.0.1', + wm_version: 'v1', + memory: 123, + occupancy_rate: 0.5 + } + ]) + + const result = await callGlobalTool('list_workers', {}) + + expect(JSON.parse(result).workers).toEqual([ + { + worker: 'wk-1', + worker_group: 'gpu', + custom_tags: ['gpu'], + last_ping: 3, + jobs_executed: 12 + } + ]) + // Page telemetry must stay out of the model's context. + expect(result).not.toContain('occupancy_rate') + expect(result).not.toContain('10.0.0.1') + }) + + it('says so when the worker page is cut short', async () => { + vi.mocked(WorkerService.listWorkers).mockResolvedValueOnce( + Array(100).fill({ + worker: 'wk', + worker_group: 'default', + custom_tags: [], + last_ping: 1, + jobs_executed: 0 + }) as any + ) + + const result = await callGlobalTool('list_workers', {}) + + // A full page is indistinguishable from the whole fleet, and the model reasons + // about tag coverage from this list. + expect(JSON.parse(result).note).toContain('Only the first 100 workers') + }) + + describe('list_workers with nothing to show', () => { + afterEach(() => { + superadmin.set(undefined) + devopsRole.set(undefined) + }) + + it('never reports an empty list as an absence to a caller workers can be hidden from', async () => { + superadmin.set(false) + devopsRole.set(false) + vi.mocked(WorkerService.listWorkers).mockResolvedValueOnce([]) + + const result = await callGlobalTool('list_workers', {}) + + // An instance hiding workers from a non-devops caller answers with an empty + // list, so absence is unprovable here. + expect(result).toContain('does NOT establish that no workers are running') + expect(result).toContain('devops role') + expect(result).not.toContain('"workers"') + }) + + it('reports an empty list as an absence to a devops caller', async () => { + devopsRole.set('devops@windmill.dev') + vi.mocked(WorkerService.listWorkers).mockResolvedValueOnce([]) + + const result = await callGlobalTool('list_workers', {}) + + // Nothing is hidden from this caller, so hedging would withhold the answer a + // stuck queue is waiting on. + expect(result).toContain('No workers are connected') + expect(result).not.toContain('does NOT establish') + }) + + it('resolves the role before deciding, rather than reading unloaded stores as no role', async () => { + // Both stores start undefined; without the refresh a devops caller whose whoami + // has not landed yet is hedged at instead of answered. + expect(get(superadmin)).toBeUndefined() + expect(get(devopsRole)).toBeUndefined() + vi.mocked(WorkerService.listWorkers).mockResolvedValueOnce([]) + + const result = await callGlobalTool('list_workers', {}) + + expect(result).toContain('No workers are connected') + }) + }) + + it('returns args, result and logs of a run in one call', async () => { + const result = await callGlobalTool('get_run', { id: 'job-123' }) expect(JobService.getJobLogs).toHaveBeenCalledWith({ workspace: WORKSPACE, id: 'job-123', + // The backend's "to remove ansi colors, use: sed ..." hint is noise for + // the model, so it is always suppressed. removeAnsiWarnings: true }) - expect(result).toBe('job log line 1\njob log line 2') - // The logs must be surfaced as the tool result so the details panel shows - // them rather than "No result yet". + const parsed = JSON.parse(result) + expect(parsed.run).toMatchObject({ + status: 'success', + path: 'f/team/runner', + args: expect.stringContaining('"n": 3'), + result: expect.stringContaining('"ok": true'), + logs: 'job log line 1\njob log line 2' + }) + // The result must be surfaced as the tool result so the details panel shows + // it rather than "No result yet". expect(toolCallbacks.setToolStatus).toHaveBeenCalledWith( - 'test-get_job_logs', - expect.objectContaining({ result: 'job log line 1\njob log line 2' }) + 'test-get_run', + expect.objectContaining({ result }) ) }) - it('reports when a job has no logs', async () => { + it('reports when a run has no logs, and tells that apart from logs it could not read', async () => { vi.mocked(JobService.getJobLogs).mockResolvedValueOnce(' ') + expect(JSON.parse(await callGlobalTool('get_run', { id: 'job-empty' })).run.logs).toBe( + 'No logs for this run.' + ) - const result = await callGlobalTool('get_job_logs', { id: 'job-empty' }) + // A failed fetch must not read as "this run logged nothing" — the model + // would report that to the user as fact. + vi.mocked(JobService.getJobLogs).mockRejectedValueOnce(new Error('boom')) + expect(JSON.parse(await callGlobalTool('get_run', { id: 'job-123' })).run.logs).toBe( + 'Logs could not be read for this run.' + ) - expect(result).toBe('No logs available for this job.') + // Nor does every failed read reject: the generated client resolves undefined + // when it cannot read the body, which lands on the same "no logs" branch. + vi.mocked(JobService.getJobLogs).mockResolvedValueOnce(undefined as any) + expect(JSON.parse(await callGlobalTool('get_run', { id: 'job-123' })).run.logs).toBe( + 'Logs could not be read for this run.' + ) + }) + + it('keeps the end of a long log, and never opens it on half a surrogate pair', async () => { + // 12002 code points over 24003 UTF-16 units: the 12000-unit tail opens one + // unit into a unicorn, so the lone low surrogate has to be dropped. + vi.mocked(JobService.getJobLogs).mockResolvedValueOnce('🦄'.repeat(12001) + 'z') + + const [note, body] = JSON.parse( + await callGlobalTool('get_run', { id: 'job-123' }) + ).run.logs.split('\n') + + // The note goes first: the tail is what the model came for, and a note at + // the end would read as the last thing the run logged. + expect(note).toContain('12002 chars total') + expect(body).toHaveLength(11999) + expect(body.codePointAt(0)).toBe(0x1f984) + expect(body.endsWith('🦄z')).toBe(true) + }) + + it('keeps the step tree optional: a failed tree fetch still returns the run itself', async () => { + vi.mocked(JobService.getFlowAllResults).mockRejectedValueOnce(new Error('tree unavailable')) + + const parsed = JSON.parse(await callGlobalTool('get_run', { id: 'job-123' })) + + expect(parsed.run).toMatchObject({ args: expect.stringContaining('"n": 3') }) + expect(parsed.run.logs).toBe('job log line 1\njob log line 2') + // Silence here would read as "this flow ran no steps", which the model + // would then report to the user as fact. + expect(parsed.run.steps_unavailable).toBe(true) + // The tree's root entry normally names the run; without it nothing does. + expect(parsed.run.job_id).toBe('job-123') + + // And the tree read fails the same two ways the log read does: reading + // `.entries` off a resolved undefined throws, which would cost the model the + // job and logs already in hand rather than just the tree. + vi.mocked(JobService.getFlowAllResults).mockResolvedValueOnce(undefined as any) + const noTree = JSON.parse(await callGlobalTool('get_run', { id: 'job-123' })) + expect(noTree.run.steps_unavailable).toBe(true) + expect(noTree.run.logs).toBe('job log line 1\njob log line 2') + }) + + it('reports why a run was canceled or died, the fields getJob used to carry', async () => { + vi.mocked(JobService.getJob).mockResolvedValueOnce({ + type: 'CompletedJob', + id: 'job-oom', + job_kind: 'script', + success: false, + canceled: true, + canceled_by: 'alice', + canceled_reason: 'exceeded memory limit', + mem_peak: 2097152 + } as any) + + const run = JSON.parse(await callGlobalTool('get_run', { id: 'job-oom' })).run + + expect(run.status).toBe('canceled') + expect(run.canceled_by).toBe('alice') + expect(run.canceled_reason).toBe('exceeded memory limit') + expect(run.mem_peak_kb).toBe(2097152) + }) + + // Over ~90KB the job endpoint elides the payload, leaving the step tree as the + // only place a real (server-side truncated) head of the result survives. + function mockElidedJob() { + vi.mocked(JobService.getJob).mockResolvedValueOnce({ + type: 'CompletedJob', + id: 'job-big', + job_kind: 'script', + success: true, + canceled: false, + args: { reason: 'WINDMILL_TOO_BIG' }, + result: 'WINDMILL_TOO_BIG' + } as any) + vi.mocked(JobService.getFlowAllResults).mockResolvedValueOnce({ + entries: [ + { + job_id: 'job-big', + label: 'Flow', + kind: 'script', + depth: 0, + sibling_index: 1, + sibling_count: 1, + status: 'success', + success: true, + result_prefix: '{"blob":"real head"}', + result_length: 300018 + } + ], + truncated: false, + scope_filtered: false + } as any) + } + + it("reports getJob's WINDMILL_TOO_BIG placeholders instead of fetching around them", async () => { + mockElidedJob() + + const run = JSON.parse(await callGlobalTool('get_run', { id: 'job-big' })).run + + // The marker is never the run's own value, so it must not reach the model. + expect(JSON.stringify(run)).not.toContain('WINDMILL_TOO_BIG') + // The result falls to the step tree's real server-side head, flagged and + // sized so the model can't mistake the fragment for the whole payload. + expect(run.result).toBe('{"blob":"real head"}') + expect(run.result_total_chars).toBe(300018) + expect(run.result_truncated).toBe(true) + expect(run.args_truncated).toBe(true) + expect(run.args).toBeUndefined() + // The endpoints that return these whole take no length parameter, so + // reaching for one would pull the entire payload into the tab. + expect(JobService.getJobArgs).not.toHaveBeenCalled() + expect(JobService.getCompletedJobResultMaybe).not.toHaveBeenCalled() + }) + + it('keeps a payload that merely carries the marker string in a reason of its own', async () => { + // The backend elides a result to the bare string and args to exactly + // {reason: marker}. A payload with that reason plus fields of its own is the + // run's own value, and withholding it would report an elision that never was. + vi.mocked(JobService.getJob).mockResolvedValueOnce({ + type: 'CompletedJob', + id: 'job-reason', + job_kind: 'script', + success: false, + canceled: false, + args: { reason: 'WINDMILL_TOO_BIG', retries: 2 }, + result: { reason: 'WINDMILL_TOO_BIG', code: 42 } + } as any) + + const run = JSON.parse(await callGlobalTool('get_run', { id: 'job-reason' })).run + + expect(run.args_truncated).toBeUndefined() + expect(run.result_truncated).toBeUndefined() + expect(run.args).toContain('"retries": 2') + expect(run.result).toContain('"code": 42') + }) + + it('reports skipped and suspended runs as such rather than success or running', async () => { + // `success` is true for a skipped job, and a suspended job is `running`. + vi.mocked(JobService.getJob).mockResolvedValueOnce({ + type: 'CompletedJob', + id: 'job-skipped', + job_kind: 'script', + success: true, + canceled: false, + is_skipped: true + } as any) + expect(JSON.parse(await callGlobalTool('get_run', { id: 'job-skipped' })).run.status).toBe( + 'skipped' + ) + + vi.mocked(JobService.getJob).mockResolvedValueOnce({ + type: 'QueuedJob', + id: 'job-suspended', + job_kind: 'flow', + running: true, + suspend: 1 + } as any) + expect(JSON.parse(await callGlobalTool('get_run', { id: 'job-suspended' })).run.status).toBe( + 'suspended' + ) }) it('searches hub scripts without fetching script contents', async () => { @@ -4925,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', @@ -4968,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, @@ -6302,7 +6709,7 @@ describe('prepareGlobalSystemMessage', () => { it('dispatches to the registered handler with the session id and default limit of 20', async () => { const callbacks: ToolCallbacks = { setToolStatus: vi.fn(), removeToolStatus: vi.fn() } const handler = vi.fn(() => ({ - aiResult: 'runs output. Next step: call get_job_logs.', + aiResult: 'runs output. Next step: call get_run.', uiMessage: 'Listed 1 app run', toolResult: '[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]' @@ -6311,7 +6718,7 @@ describe('prepareGlobalSystemMessage', () => { const result = await callGlobalTool('list_app_runs', {}, callbacks, { sessionId: 'sess-runs' }) - expect(result).toBe('runs output. Next step: call get_job_logs.') + expect(result).toBe('runs output. Next step: call get_run.') expect(handler).toHaveBeenCalledWith({ sessionId: 'sess-runs', limit: 20 }) expect(callbacks.setToolStatus).toHaveBeenLastCalledWith('test-list_app_runs', { content: 'Listed 1 app run', diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 52f4ee39ef..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, @@ -17,7 +18,8 @@ import { ScriptService, SqsTriggerService, VariableService, - WebsocketTriggerService + WebsocketTriggerService, + WorkerService } from '$lib/gen' import { createTwoFilesPatch } from 'diff' import { deepEqual } from 'fast-equals' @@ -30,7 +32,6 @@ import type { Flow, FlowModule, FlowValue, - Job, ListableApp, ListableResource, ListableVariable, @@ -180,6 +181,7 @@ import { workspaceStore } from '$lib/stores' import { getWorkspaceRole, type RoleLookup } from '$lib/user' +import { refreshSuperadmin } from '$lib/refreshUser' import { get } from 'svelte/store' import { canonicalDraftSideValue, @@ -187,7 +189,7 @@ import { getDraftDiffValues } from '$lib/utils_draft_deploy' import { changedLineIndices, draftDeployedPatch, windowPatch } from './draftDiff' -import { getFlowRunDetails } from './flowRunTree' +import { getRun, summarizeRun } from './flowRunTree' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { invalidateWorkspaceComparison } from '$lib/workspaceComparison' import type { UserDraftItemKind } from '$lib/gen' @@ -693,17 +695,13 @@ const searchResourceTypesSchema = z.object({ .describe('Max number of resource types to return. Defaults to 5.') }) -const getJobLogsSchema = z.object({ - id: z.string().describe('The UUID of the job to fetch logs for.') -}) - -const getFlowRunDetailsSchema = z.object({ - id: z.string().describe('The UUID of the flow run to inspect.'), +const getRunSchema = z.object({ + id: z.string().describe('The UUID of the run (job) to inspect.'), step: z .string() .optional() .describe( - 'Step to drill into for its result (returned in full up to 12k chars), addressed by the step ids shown in the tree: "b" for a top-level step, "b/c" for a step inside a subflow, "b[12]" for iteration 12 of a loop or attempt 12 of a retried step (1-based), composable as "b[12]/c". Omit to get the whole per-step tree.' + 'Step to drill into for its result (returned in full up to 12k chars), addressed by the step ids shown in the tree: "b" for a top-level step, "b/c" for a step inside a subflow, "b[12]" for iteration 12 of a loop or attempt 12 of a retried step (1-based), composable as "b[12]/c". Omit to get the run itself.' ) }) @@ -729,6 +727,17 @@ const listRunsSchema = z.object({ .describe('Max number of runs to return, most recent first. Defaults to 30.') }) +// `GET /workers/list` hides workers from a caller without the devops role by +// returning an empty list, not an error, when HIDE_WORKERS_FOR_NON_ADMINS is set. +// The flag is not visible here, so absence is only provable for a devops or +// superadmin caller; every other caller gets the hedge even where nothing is hidden. +const NO_WORKERS_VISIBLE_MESSAGE = + 'No workers came back. This does NOT establish that no workers are running: an instance can hide workers from callers without the devops role, and it does so by returning an empty list rather than an error. ' + + 'Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves. Never state that no workers are online or that the instance has none.' +const NO_WORKERS_CONNECTED_MESSAGE = + 'No workers are connected to this instance (none pinged in the last 5 minutes). Queued runs will stay queued until a worker starts.' +const WORKER_PAGE_SIZE = 100 + const deleteWorkspaceItemSchema = z.object({ type: itemTypeSchema, path: z.string().describe('Workspace path of the item to delete.'), @@ -926,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 }) @@ -1356,8 +1376,8 @@ Rules: ${pipelineBullet} - After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. - Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested. -- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run. -- To see what a flow run actually did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — use get_flow_run_details with the run id (it also works while the flow is still running). Pass step to read one step's result in full (capped at 12k chars). Prefer it over get_job_logs when you need step results rather than logs. +- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run. +- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars). - Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Carry over every filter the user described — Runs takes the page's whole filter set (time window, path, user, folder, label, tag, worker, trigger kind, args/result, ...), so don't drop a criterion just because it wasn't in the request's main clause. Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself. - Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its edit drawer, or the page's filters otherwise). Never just describe where to click. - When the user is happy with the changes and wants to review or deploy them, use open_page with page "compare" — it opens the Compare & Deploy review page.${ @@ -1365,7 +1385,7 @@ ${pipelineBullet} ? ' By default it preselects the items this chat modified; pass items (":" entries) to control the selection' : ' Pass items (":" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace' }, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed. -- For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead. +- For a Windmill operation no other tool covers (queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead. - Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's. - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive). - When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task. @@ -1376,7 +1396,7 @@ ${pipelineBullet} - Building a data pipeline: call open_preview(kind="pipeline", path="") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty or not-yet-created folder is fine (create_folder first if needed, then open it). Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor. - When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app"). - To inspect what actually rendered in a running raw app (verify an edit landed on screen, diagnose a blank/empty or wrong view, answer "what's showing"), use search_dom (regex over the live HTML) and read_dom (a line-numbered window). Pass a \`selector\` to scope to an element — prefer the selector from a DOM element chip the user attached — or omit it for the whole page. When a chip lists an \`app_path\`, pass it too so the RIGHT app is read (several previews can be open; a query without \`app_path\` hits the visible one). The DOM is read live and is never in context; no match means the element isn't rendered. Both need the raw app preview open. -- get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend. call), call list_app_runs to get that run's job_id from the live preview, then get_job_logs with it. Use this when a backend call errors or returns something unexpected. +- get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend. call), call list_app_runs to get that run's job_id from the live preview, then get_run with it. Use this when a backend call errors or returns something unexpected. ${ isChromiumBrowser() ? `- When the user raises how a raw app looks (something is off, or they want the design or layout improved), call take_screenshot to see what they are looking at before changing anything. Reach for it when the request is about appearance, not to review your own edits, which you can read back from the code. It needs the raw app preview open (open_preview kind="raw_app").` @@ -1555,36 +1575,6 @@ function variableToItem(variable: ListableVariable): WorkspaceItem { } } -// Compact metadata for one run. The raw Job carries args/result/logs/raw_code -// which can be huge — list_runs returns only what's needed to identify a run. -function summarizeRun(job: Job): Record { - const base = { - id: job.id, - job_kind: job.job_kind, - path: job.script_path, - created_by: job.created_by, - created_at: job.created_at, - started_at: job.started_at, - schedule_path: job.schedule_path, - is_flow_step: job.is_flow_step, - tag: job.tag, - worker: job.worker - } - if ('success' in job) { - // CompletedJob - return { - ...base, - status: job.canceled ? 'canceled' : job.success ? 'success' : 'failure', - duration_ms: job.duration_ms - } - } - // QueuedJob (running or still waiting in the queue) - return { - ...base, - status: job.running ? 'running' : 'queued' - } -} - // ============= App helpers ============= type BackendRunnableInput = z.infer @@ -3752,7 +3742,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( listRunsSchema, 'list_runs', - "List recent runs (jobs), most recent first. Optionally filter by path, creator, label, or status. Returns compact metadata only — use get_job_logs with a returned id to read a run's logs." + 'List recent runs (jobs), most recent first. Optionally filter by path, creator, label, or status. Returns compact metadata only — use get_run with a returned id to see what a run was called with, returned and logged.' ), planModeSafe: true, showDetails: true, @@ -3779,24 +3769,42 @@ export const globalTools: Tool<{}>[] = [ }, { def: createToolDef( - getFlowRunDetailsSchema, - 'get_flow_run_details', - "Inspect a flow run's execution tree: per-step statuses and truncated results, including subflow steps, loop iterations, branches, and retries. Works on running flows too. Pass step to fetch one step's result in full (up to 12k chars)." + z.object({}), + 'list_workers', + 'List the workers connected to this Windmill instance (those that pinged in the last 5 minutes), with their worker group, custom tags, seconds since their last ping, and jobs executed. Pair with list_runs to diagnose a stuck queue: runs queued on a tag no listed worker picks up will never start. Three blind spots to report rather than reason past: an empty result states whether no worker is connected or whether workers may be hidden from you, so relay the one it gives instead of picking; a missing custom_tags can mean tags are hidden from you, not unset; and only the 100 most recently pinging workers are listed, so on a bigger instance a tag none of them carries may still be served.' ), planModeSafe: true, showDetails: true, - fn: async ({ args, workspace, toolId, toolCallbacks }) => { - const parsed = getFlowRunDetailsSchema.parse(args) + fn: async ({ toolId, toolCallbacks }) => { + toolCallbacks.setToolStatus(toolId, { content: 'Listing workers...' }) + const pings = await WorkerService.listWorkers({ perPage: WORKER_PAGE_SIZE }) + if (pings.length === 0) { + // Both role stores resolve asynchronously, and an unloaded one must not read as + // an absent role — that hedges the answer this branch exists to give plainly. + // No-ops once they hold a value. + await refreshSuperadmin() + const hiddenFromCaller = !get(superadmin) && !get(devopsRole) + const message = hiddenFromCaller ? NO_WORKERS_VISIBLE_MESSAGE : NO_WORKERS_CONNECTED_MESSAGE + toolCallbacks.setToolStatus(toolId, { + content: hiddenFromCaller ? 'No workers visible' : 'No workers connected', + result: message + }) + return message + } + const workers = pings.map((w) => ({ + worker: w.worker, + worker_group: w.worker_group, + custom_tags: w.custom_tags, + last_ping: w.last_ping, + jobs_executed: w.jobs_executed + })) + const note = + workers.length === WORKER_PAGE_SIZE + ? `Only the first ${WORKER_PAGE_SIZE} workers are listed; more may be connected.` + : undefined + const result = JSON.stringify({ workers, ...(note ? { note } : {}) }, null, 2) toolCallbacks.setToolStatus(toolId, { - content: parsed.step - ? `Fetching result of step ${parsed.step} in run ${parsed.id}...` - : `Inspecting flow run ${parsed.id}...` - }) - const result = await getFlowRunDetails(workspace, parsed.id, parsed.step) - toolCallbacks.setToolStatus(toolId, { - content: parsed.step - ? `Fetched result of step ${parsed.step} in run ${parsed.id}` - : `Inspected flow run ${parsed.id}`, + content: `Listed ${workers.length} worker(s)`, result }) return result @@ -3804,31 +3812,24 @@ export const globalTools: Tool<{}>[] = [ }, { def: createToolDef( - getJobLogsSchema, - 'get_job_logs', - 'Fetch the logs of a job by its id. Use this to inspect the output of an existing run.' + getRunSchema, + 'get_run', + "Inspect one run: its status, arguments, result and logs, plus — for a flow — the per-step execution tree with each step's status and truncated result (subflow steps, loop iterations, branches and retries included). Works on running jobs too. Pass step to fetch one step's result in full (up to 12k chars)." ), planModeSafe: true, showDetails: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { - const parsed = getJobLogsSchema.parse(args) + const parsed = getRunSchema.parse(args) toolCallbacks.setToolStatus(toolId, { - content: `Fetching logs for job ${parsed.id}...` + content: parsed.step + ? `Fetching result of step ${parsed.step} in run ${parsed.id}...` + : `Inspecting run ${parsed.id}...` }) - const logs = await JobService.getJobLogs({ - workspace, - id: parsed.id, - // Always suppress the "to remove ansi colors, use: sed ..." hint the - // backend otherwise prepends — it is noise for the model and is not - // actual ANSI stripping (the raw logs are returned either way). - removeAnsiWarnings: true - }) - const hasLogs = typeof logs === 'string' && logs.trim().length > 0 - const result = hasLogs ? logs : 'No logs available for this job.' + const result = await getRun(workspace, parsed.id, parsed.step) toolCallbacks.setToolStatus(toolId, { - content: hasLogs - ? `Fetched logs for job ${parsed.id}` - : `No logs available for job ${parsed.id}`, + content: parsed.step + ? `Fetched result of step ${parsed.step} in run ${parsed.id}` + : `Inspected run ${parsed.id}`, result }) return result @@ -4330,7 +4331,7 @@ export const globalTools: Tool<{}>[] = [ }, // Workspace-scoped datatable tools (unrestricted: no whitelist, no creation policy) ...getDatatableTools(), - // Workspace DuckLake readiness (storage prerequisite check for pipelines) + // Workspace DuckLake: pipeline storage prerequisite, and declared measures ...getDucklakeTools(), // Read-only tools over files the user attached to the conversation ...fileTools, @@ -4402,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 @@ -4440,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: { @@ -5461,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 @@ -5926,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 } }) @@ -6569,7 +6589,7 @@ async function testRunAppRunnable( toolId, startMessage: `Running backend runnable "${key}" of app "${path}"...`, // A path runnable pointing at a flow really does queue a flow job, so the - // failure path can offer get_flow_run_details; everything else is a script job. + // failure path can offer get_run's step tree; everything else is a script job. contextName: runnable.runType === 'flow' ? 'flow' : 'script', completionName: 'backend runnable', background: args.background, diff --git a/frontend/src/lib/components/copilot/chat/global/flowRunTree.test.ts b/frontend/src/lib/components/copilot/chat/global/flowRunTree.test.ts index 4b0dee5de0..7b2ddeb5fc 100644 --- a/frontend/src/lib/components/copilot/chat/global/flowRunTree.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/flowRunTree.test.ts @@ -190,4 +190,29 @@ describe('shapeFlowRunTree', () => { expect(parsed.steps[1].result).toBeUndefined() expect(parsed.steps[1].result_total_chars).toBe(700) }) + + it('spends the tree budget on the tree, not on the run payloads carried with it', () => { + const entries = [root()] + for (let i = 1; i <= 12; i++) { + entries.push( + entry({ + step_path: `s${i}`, + flow_step_id: `s${i}`, + status: 'failure', + success: false, + result_prefix: 'y'.repeat(700), + result_length: 700 + }) + ) + } + const logs = 'L'.repeat(12000) + const rendered = shapeFlowRunTree({ entries }, { status: 'failure', logs }) + + // Counting the overrides against the budget would shrink this tree away + // even though the tree itself fits: the model asked for the payloads. + expect(rendered.length).toBeGreaterThan(20000) + const parsed = JSON.parse(rendered) + expect(parsed.run.logs).toBe(logs) + expect(parsed.steps[0].result.length).toBe(700) + }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/flowRunTree.ts b/frontend/src/lib/components/copilot/chat/global/flowRunTree.ts index 207dca1789..7f63de373b 100644 --- a/frontend/src/lib/components/copilot/chat/global/flowRunTree.ts +++ b/frontend/src/lib/components/copilot/chat/global/flowRunTree.ts @@ -1,19 +1,25 @@ -import { JobService, type GetFlowAllResultsResponse } from '$lib/gen' +import { JobService, type Job, type GetFlowAllResultsResponse } from '$lib/gen' +import { isWindmillTooBigObject } from '$lib/components/job_args' /** - * Model-facing view of a flow run's execution tree for the global chat's - * get_flow_run_details tool. The backend endpoint (get_flow_all_results) - * enumerates every job of the tree with per-entry truncated results; this - * module shapes that flat list into a compact per-step tree the model can - * read in one tool result. Step addresses ('b/c', 'b[12]/c') are resolved - * server-side by the same endpoint for full-result drill-down. + * Model-facing view of a run for the global chat's get_run tool: the job's own + * summary, args, result and logs, plus — when the run has steps — its execution + * tree. The backend endpoint (get_flow_all_results) enumerates every job of the + * tree with per-entry truncated results; this module shapes that flat list into + * a compact per-step tree the model can read in one tool result. Step addresses + * ('b/c', 'b[12]/c') are resolved server-side by the same endpoint for + * full-result drill-down. + * + * `summarizeRun` also backs list_runs' per-job summary, so it lives here rather + * than in core.ts: get_run needs it, and importing it back would be circular. */ export type FlowResultEntry = GetFlowAllResultsResponse['entries'][number] /** Per-entry result budget requested from the server for the tree view. */ export const TREE_RESULT_HEAD_CHARS = 700 -/** Cap on a drilled single-step full result handed to the model. */ +/** Cap on a full payload handed to the model: a drilled step result, and the + * run's own args, result and logs. */ export const STEP_RESULT_MAX_CHARS = 12000 /** Cap on the whole rendered tree; heads shrink progressively to fit. */ const TREE_TOTAL_BUDGET_CHARS = 20000 @@ -97,6 +103,13 @@ function sliceCodePointSafe(s: string, maxUnits: number): string { return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut } +function sliceCodePointSafeEnd(s: string, maxUnits: number): string { + const cut = s.slice(-maxUnits) + const first = cut.charCodeAt(0) + // drop a leading lone low surrogate + return first >= 0xdc00 && first <= 0xdfff ? cut.slice(1) : cut +} + function shapeResult( entry: FlowResultEntry, opts: ShapeOpts @@ -211,7 +224,8 @@ function shapeChildren(children: FlowTreeNode[], opts: ShapeOpts): Record ): Record { const run = shapeStep(root, opts) const steps = run.steps @@ -221,19 +235,41 @@ function renderTree( if (!steps && root.entry.kind !== 'flow' && root.entry.kind !== 'flowpreview') { run.label = `Job (${root.entry.kind})` } + if (runOverrides) { + // A replacement result supersedes the tree entry's head, and the stale + // truncation marker must go with it. Without one the head stays: it is a + // real server-side prefix of a payload `getJob` would only hand back as a + // WINDMILL_TOO_BIG placeholder. + if ('result' in runOverrides) { + delete run.result + delete run.result_total_chars + } + Object.assign(run, runOverrides) + } return { ...(rootJobNote ? { note: rootJobNote } : {}), run, - ...(steps ? { steps } : {}), - hint: `Results are truncated. Call get_flow_run_details again with step="" (e.g. "b/c", or "b[12]" for one loop iteration) for a step's result in full (up to ${STEP_RESULT_MAX_CHARS} chars).` + ...(steps + ? { + steps, + hint: `Step results are truncated. Call get_run again with step="" (e.g. "b/c", or "b[12]" for one loop iteration) for a step's result in full (up to ${STEP_RESULT_MAX_CHARS} chars), or with id set to a step's job_id for that step's own args, result and logs.` + } + : {}) } } /** Render the whole tree, shrinking result heads until it fits the budget. */ -export function shapeFlowRunTree(response: GetFlowAllResultsResponse): string { +export function shapeFlowRunTree( + response: GetFlowAllResultsResponse, + runOverrides?: Record +): string { const root = buildFlowTree(response.entries) if (!root) { - return 'No jobs found for this run.' + // The tree is the optional half: what the caller already read of the job + // still answers the question, so don't drop it with the missing tree. + return runOverrides + ? JSON.stringify({ run: runOverrides }, null, 1) + : 'No jobs found for this run.' } const notes = [ ...(response.enclosing_job @@ -252,34 +288,188 @@ export function shapeFlowRunTree(response: GetFlowAllResultsResponse): string { ] const rootJobNote = notes.length > 0 ? notes.join(' ') : undefined + // The budget caps the tree, not the run's own args/result/logs — those are + // capped on their own and the model asked for them, so they don't count here + // and the last-resort slice keeps room for them (`run` precedes `steps`). + const overrideChars = runOverrides ? JSON.stringify(runOverrides, null, 1).length : 0 let rendered = '' for (const opts of SHRINK_LADDER) { - rendered = JSON.stringify(renderTree(root, rootJobNote, opts), null, 1) - if (rendered.length <= TREE_TOTAL_BUDGET_CHARS) { + rendered = JSON.stringify(renderTree(root, rootJobNote, opts, runOverrides), null, 1) + if (rendered.length - overrideChars <= TREE_TOTAL_BUDGET_CHARS) { return rendered } } return ( - rendered.slice(0, TREE_TOTAL_BUDGET_CHARS) + + rendered.slice(0, TREE_TOTAL_BUDGET_CHARS + overrideChars) + `\n… (tree truncated at ${TREE_TOTAL_BUDGET_CHARS} chars — drill into specific steps with the step parameter)` ) } -/** Entry point of the get_flow_run_details tool. Without `step`: the compact - * tree. With `step`: that job's full (capped) result, resolved server-side. */ -export async function getFlowRunDetails( - workspace: string, - id: string, - step?: string -): Promise { +// Compact metadata for one run. The raw Job carries args/result/logs/raw_code +// which can be huge — this is only what's needed to identify a run, so list_runs +// can return one entry per job. +export function summarizeRun(job: Job): Record { + const base = { + id: job.id, + job_kind: job.job_kind, + path: job.script_path, + created_by: job.created_by, + created_at: job.created_at, + started_at: job.started_at, + schedule_path: job.schedule_path, + is_flow_step: job.is_flow_step, + tag: job.tag, + worker: job.worker + } + if ('success' in job) { + // CompletedJob. `success` is true for a skipped job too, so is_skipped has + // to be read first or a skipped step reports as a successful one. + return { + ...base, + status: job.canceled + ? 'canceled' + : job.is_skipped + ? 'skipped' + : job.success + ? 'success' + : 'failure', + duration_ms: job.duration_ms + } + } + // QueuedJob. A running job with suspends outstanding is parked — on an approval + // step or on a parallelism slot — not working, and `running` alone hides that. + return { + ...base, + status: job.running ? (job.suspend ? 'suspended' : 'running') : 'queued' + } +} + +/** Cap a payload to STEP_RESULT_MAX_CHARS. `tail` keeps the end instead of the + * start — for logs, where the failure is at the bottom. + * + * Counts and cuts without materialising the string: logs arrive whole and + * unbounded, and spreading one into an array of code points costs ~9x its size + * (a 10MB log measured +90MB). Cutting in UTF-16 units keeps at most the budget + * in code points, never more, so an astral-heavy payload is trimmed slightly + * shorter than advertised rather than overshooting. */ +function cap(text: string, tail = false): string { + // UTF-16 length is never below the code-point count, so anything passing this + // is already under the cap and needs no counting pass at all. + if (text.length <= STEP_RESULT_MAX_CHARS) return text + const total = countCodePoints(text) + if (total <= STEP_RESULT_MAX_CHARS) return text + const note = `… (truncated: ${total} chars total)` + return tail + ? `${note}\n${sliceCodePointSafeEnd(text, STEP_RESULT_MAX_CHARS)}` + : `${sliceCodePointSafe(text, STEP_RESULT_MAX_CHARS)}\n${note}` +} + +/** Told apart from an empty log so the model doesn't report "no logs" for a run + * whose logs it simply failed to read. */ +const LOGS_UNREADABLE = 'Logs could not be read for this run.' + +/** `getJob` swaps a payload over ~90KB for a marker rather than sending it + * (`get_job_query!` in backend/windmill-api/src/jobs.rs), and the two fields use + * different ones: a result becomes the bare string, args become exactly + * `{reason: }`. Each field matches only its own form, so a payload that + * merely carries that string in a `reason` of its own stays the run's value. */ +const TOO_BIG_RESULT = 'WINDMILL_TOO_BIG' + +function stringify(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value, null, 1) +} + +/** An elided args/result payload is reported, never fetched around: the + * endpoints that return those whole take no length parameter, so recovering a + * usable head would mean pulling the entire payload (up to MAX_RESULT_SIZE_MB, + * 500 by default) into the tab to keep 12k of it. The flag is the whole report — + * the result keeps the head the step tree already carries beside it. */ +function shapeRunArgs(job: Job): Record { + if (!job.args) return {} + return isWindmillTooBigObject(job.args) + ? { args_truncated: true } + : { args: cap(stringify(job.args)) } +} + +function shapeRunResult(job: Job): Record { + if (!('result' in job) || job.result === undefined) return {} + // No `result` key when elided, so the tree's `result_prefix` — a real + // server-side head of the same payload — stands in its place unoverridden. + return job.result === TOO_BIG_RESULT + ? { result_truncated: true } + : { result: cap(stringify(job.result)) } +} + +/** Why a run ended badly. get_run is the only route to these — the catalog + * refuses getJob. Kept out of summarizeRun, which list_runs pays per job. */ +function diagnoseRun(job: Job): Record { + return { + ...(job.canceled_by ? { canceled_by: job.canceled_by } : {}), + ...(job.canceled_reason ? { canceled_reason: job.canceled_reason } : {}), + ...(job.mem_peak ? { mem_peak_kb: job.mem_peak } : {}) + } +} + +/** Entry point of the get_run tool. Without `step`: the run's summary, args, + * result and logs, plus the per-step tree when the run has steps. With `step`: + * that step's full (capped) result, resolved server-side. */ +export async function getRun(workspace: string, id: string, step?: string): Promise { if (!step) { - return shapeFlowRunTree( - await JobService.getFlowAllResults({ workspace, id, maxResultLen: TREE_RESULT_HEAD_CHARS }) - ) + // Only the job read is load-bearing: logs and the step tree each answer + // part of the question, so neither failing should cost the model the rest. + const [job, logs, results] = await Promise.all([ + JobService.getJob({ workspace, id, noLogs: true, noCode: true }), + // The dedicated endpoint rather than the job's own `logs` field: that one + // is the last 20k still in the DB column, missing the head that log + // compaction flushed to object storage. This one stitches them back. + // + // It takes no length parameter, so unlike args and result the whole log + // does come into the tab before being capped. Only this job's own logs, + // though: a flow's are its orchestration lines, not its steps'. + JobService.getJobLogs({ + workspace, + id, + // Suppress the "to remove ansi colors, use: sed ..." hint the backend + // otherwise prepends — noise for the model, and not actual stripping. + removeAnsiWarnings: true + }).catch(() => LOGS_UNREADABLE), + // `.catch` alone would leave the tree half-guarded: like the log read, this + // one can fail by resolving `undefined` rather than rejecting, and reading + // `.entries` off that throws — losing the job and logs already in hand. + JobService.getFlowAllResults({ workspace, id, maxResultLen: TREE_RESULT_HEAD_CHARS }) + .catch(() => undefined) + .then((r) => r ?? ({ entries: [] } as GetFlowAllResultsResponse)) + ]) + const { id: _id, ...summary } = summarizeRun(job) + const payloads = { ...shapeRunArgs(job), ...shapeRunResult(job) } + // A read that fails without rejecting still arrives here: the generated client + // resolves `undefined` when it cannot read the body (a truncated response, a + // dropped connection). Logs are the one payload where empty is a real answer, + // so that has to be told apart from an empty log rather than reported as one. + const shapedLogs = + typeof logs !== 'string' || logs === LOGS_UNREADABLE + ? LOGS_UNREADABLE + : logs.trim() + ? cap(logs, true) + : 'No logs for this run.' + return shapeFlowRunTree(results, { + ...summary, + ...diagnoseRun(job), + // A successful read always carries the job itself as the root entry, so + // no entries means the read failed — and nothing else would name the run. + ...(results.entries.length === 0 ? { job_id: id, steps_unavailable: true } : {}), + ...payloads, + logs: shapedLogs + }) } - // Drill-down: the server resolves the address directly (a few indexed - // lookups, no tree enumeration) and returns the single job as an entry. + return getStepResult(workspace, id, step) +} + +/** One step's result in full, addressed by step path. The server resolves the + * address directly (a few indexed lookups, no tree enumeration) and returns the + * single job as an entry. */ +async function getStepResult(workspace: string, id: string, step: string): Promise { const response = await JobService.getFlowAllResults({ workspace, id, 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...' }) diff --git a/frontend/src/lib/components/copilot/chat/messageDraft.svelte.ts b/frontend/src/lib/components/copilot/chat/messageDraft.svelte.ts index 264a438d2b..b64711dccf 100644 --- a/frontend/src/lib/components/copilot/chat/messageDraft.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/messageDraft.svelte.ts @@ -1,6 +1,6 @@ /** - * A message draft: the four lanes that ship together with one send — text, - * pastes, images, text files. Every place a draft accumulates or moves + * A message draft: the five lanes that ship together with one send — text, + * pastes, images, text files, blobs. Every place a draft accumulates or moves * (composer attach, queue append, dequeue restore, failure restore) goes * through this type, so the draft rules — file dedupe by source identity, * courtesy rename, attachment slot caps, all-lanes-move-together — live here @@ -10,6 +10,7 @@ * manager-wide state — enforced at the composer until it moves into the * store) and @context/DOM picks (ContextManager owns their lifecycle). */ +import { MAX_ATTACHED_BLOBS, type AttachedBlob } from './blobUtils' import { MAX_ATTACHED_IMAGES, type AttachedImage } from './imageUtils' import type { PasteAttachment } from './pasteTokens' import { @@ -19,12 +20,13 @@ import { type AttachedTextFile } from './textFileUtils' -/** A draft's four lanes as plain data — what moves between owners. */ +/** A draft's five lanes as plain data — what moves between owners. */ export interface DraftSnapshot { text: string pastes: PasteAttachment[] images: AttachedImage[] files: AttachedTextFile[] + blobs: AttachedBlob[] } export class MessageDraft { @@ -32,12 +34,14 @@ export class MessageDraft { pastes = $state([]) images = $state([]) files = $state([]) + blobs = $state([]) constructor(seed?: Partial) { if (seed?.text) this.text = seed.text if (seed?.pastes) this.pastes = [...seed.pastes] if (seed?.images) this.images = [...seed.images] if (seed?.files) this.files = [...seed.files] + if (seed?.blobs) this.blobs = [...seed.blobs] } get isEmpty(): boolean { @@ -45,12 +49,13 @@ export class MessageDraft { this.text.trim() === '' && this.pastes.length === 0 && this.images.length === 0 && - this.files.length === 0 + this.files.length === 0 && + this.blobs.length === 0 ) } get hasAttachments(): boolean { - return this.images.length > 0 || this.files.length > 0 + return this.images.length > 0 || this.files.length > 0 || this.blobs.length > 0 } /** Files joining a draft always fold (dedupe by source identity, courtesy @@ -83,6 +88,14 @@ export class MessageDraft { return dropped } + /** Blobs join up to the slot cap. Returns the dropped count (caller toasts). */ + addBlobs(blobs: AttachedBlob[]): number { + const merged = [...this.blobs, ...blobs] + const dropped = Math.max(0, merged.length - MAX_ATTACHED_BLOBS) + this.blobs = merged.slice(0, MAX_ATTACHED_BLOBS) + return dropped + } + /** * Merge a restored draft on top of this one (queued-message delete, restore * after a cancelled/errored turn): the restored draft was written FIRST, so @@ -91,10 +104,16 @@ export class MessageDraft { * Returns whether text merged onto a non-empty draft (the caller must then * keep both drafts' context), plus dropped counts for toasts. */ - prepend(restored: { text: string; images?: AttachedImage[]; files?: AttachedTextFile[] }): { + prepend(restored: { + text: string + images?: AttachedImage[] + files?: AttachedTextFile[] + blobs?: AttachedBlob[] + }): { mergedIntoDraft: boolean droppedImages: number droppedFiles: number + droppedBlobs: number } { const mergedIntoDraft = !!restored.text && !!this.text.trim() // An attachment-only restore has empty text; prepending would only add blank lines. @@ -115,7 +134,13 @@ export class MessageDraft { droppedFiles = Math.max(0, merged.length - MAX_ATTACHED_FILES) this.files = merged.slice(0, MAX_ATTACHED_FILES) } - return { mergedIntoDraft, droppedImages, droppedFiles } + let droppedBlobs = 0 + if (restored.blobs?.length) { + const merged = [...restored.blobs, ...this.blobs] + droppedBlobs = Math.max(0, merged.length - MAX_ATTACHED_BLOBS) + this.blobs = merged.slice(0, MAX_ATTACHED_BLOBS) + } + return { mergedIntoDraft, droppedImages, droppedFiles, droppedBlobs } } /** Replace the draft with a snapshot, but only when it is empty — an occupied @@ -132,16 +157,18 @@ export class MessageDraft { this.pastes = [...(snapshot.pastes ?? [])] this.images = [...(snapshot.images ?? [])] this.files = [...(snapshot.files ?? [])] + this.blobs = [...(snapshot.blobs ?? [])] } - /** Snapshot and clear atomically — the four lanes always move together, so no + /** Snapshot and clear atomically — the five lanes always move together, so no * call site can take one and forget another. */ take(): DraftSnapshot { const snapshot: DraftSnapshot = { text: this.text, pastes: this.pastes, images: this.images, - files: this.files + files: this.files, + blobs: this.blobs } this.clear() return snapshot @@ -152,5 +179,6 @@ export class MessageDraft { this.pastes = [] this.images = [] this.files = [] + this.blobs = [] } } diff --git a/frontend/src/lib/components/copilot/chat/messageDraft.test.ts b/frontend/src/lib/components/copilot/chat/messageDraft.test.ts index a6a9bb6169..dec8e1e6b9 100644 --- a/frontend/src/lib/components/copilot/chat/messageDraft.test.ts +++ b/frontend/src/lib/components/copilot/chat/messageDraft.test.ts @@ -5,22 +5,49 @@ import { MessageDraft } from './messageDraft.svelte' // the draft-level guarantees: lanes move together, restores respect occupancy, // aggregation always applies the rules. +function blob(name: string) { + return { + name, + mediaType: 'application/pdf', + dataUrl: 'data:application/pdf;base64,JVBERg==', + size: 4 + } +} + describe('MessageDraft', () => { - it('take() snapshots and clears all four lanes atomically', () => { + it('take() snapshots and clears all five lanes atomically', () => { const d = new MessageDraft({ text: 'hello', pastes: [{ id: 'p1', content: 'x' } as any], images: [{ dataUrl: 'i1' } as any], - files: [{ name: 'a.md', content: 'a' }] + files: [{ name: 'a.md', content: 'a' }], + blobs: [blob('a.pdf')] }) const snap = d.take() expect(snap.text).toBe('hello') expect(snap.pastes).toHaveLength(1) expect(snap.images).toHaveLength(1) expect(snap.files).toHaveLength(1) + expect(snap.blobs).toHaveLength(1) expect(d.isEmpty).toBe(true) }) + it('treats a blob-only draft as occupied and caps blobs, keeping restored ones first', () => { + const d = new MessageDraft({ blobs: [blob('only.pdf')] }) + expect(d.isEmpty).toBe(false) + expect(d.hasAttachments).toBe(true) + expect(d.replaceIfEmpty({ text: 'restored' })).toBe(false) + + const dropped = d.addBlobs(Array.from({ length: 8 }, (_, i) => blob(`new${i}.pdf`))) + expect(dropped).toBe(1) + expect(d.blobs).toHaveLength(8) + + const res = d.prepend({ text: '', blobs: [blob('old.pdf')] }) + expect(res.droppedBlobs).toBe(1) + expect(d.blobs[0].name).toBe('old.pdf') + expect(d.blobs).toHaveLength(8) + }) + it('replaceIfEmpty declines when any lane is occupied', () => { const d = new MessageDraft({ files: [{ name: 'a.md', content: 'a' }] }) expect(d.replaceIfEmpty({ text: 'restored' })).toBe(false) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 73efd2e37e..13799cf09f 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 } /** @@ -1853,13 +1861,13 @@ export async function buildTestRunArgs( } // The string handed back to the model when a job is backgrounded. It carries the -// job id so the model can pull status/logs on demand (get_job_logs / list_runs), +// job id so the model can pull status/args/result/logs on demand (get_run / list_runs), // and tells it the completion will be reported later (notify-only wake). function backgroundedSummary(jobId: string, label: string): string { return ( `Job ${jobId} for "${label}" is taking a while and is now running in the background — ` + `the chat is free to continue and you'll be told when it finishes. ` + - `To inspect it now, call get_job_logs with id="${jobId}" (or list_runs); ` + + `To inspect it now, call get_run with id="${jobId}" (or list_runs); ` + `to stop it, call cancel_job with id="${jobId}".` ) } @@ -1890,7 +1898,7 @@ export function completedJobToolStatus(job: CompletedJob): Partial { }) const summary = formatResultSummary(job.result, job.logs, job.success) - // get_flow_run_details only exists in the global/sessions chat (the same - // hosts that wire the job hooks) — don't advertise it to in-editor chats. + // get_run only exists in the global/sessions chat (the same hosts that wire + // the job hooks) — don't advertise it to in-editor chats. if (detachEnabled && config.contextName === 'flow' && !job.success) { return ( summary + - `\n\nFor per-step statuses and results (subflow steps included), call get_flow_run_details with id="${jobId}".` + `\n\nFor per-step statuses and results (subflow steps included), call get_run with id="${jobId}".` ) } return summary 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/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index d38c4cb6fe..4f91f1800f 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -6,14 +6,23 @@ import { twMerge } from 'tailwind-merge' import { userStore } from '$lib/stores' import { createEventDispatcher, getContext, tick } from 'svelte' + import { MediaQuery } from 'svelte/reactivity' import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte' import type { TriggerContext } from '../triggers' - import { Calendar } from 'lucide-svelte' + import type { Item } from '$lib/utils' + import { Bell, BellOff, Calendar } from 'lucide-svelte' + import { toggleWorkspaceErrorHandler } from './errorHandlerToggle' type MainButton = { label: string - href: string + /** Shown under the label once the button has collapsed into a menu. */ + description?: string buttonProps: ButtonProps + /** Where the button lands below the `lg` breakpoint, where the bar cannot hold every + * button beside the summary: the ellipsis menu, or the dropdown of the enabled bar button + * labelled `dropdownOf` (the menu when there is none). Unset keeps it in the bar at every + * width. */ + narrow?: 'menu' | { dropdownOf: string } } type ButtonProps = any @@ -59,12 +68,76 @@ }: Props = $props() const dispatch = createEventDispatcher() + + // Tailwind's `lg`, matched in JS so the one ellipsis menu can carry the collapsed buttons. + const wide = new MediaQuery('(min-width: 1024px)') + + const barButtons = $derived(wide.current ? mainButtons : mainButtons.filter((b) => !b.narrow)) + + function dropdownHost(btn: MainButton): MainButton | undefined { + if (typeof btn.narrow !== 'object') return undefined + const label = btn.narrow.dropdownOf + return barButtons.find((b) => b.label === label && !b.buttonProps.disabled) + } + + async function toggleErrorHandler() { + const next = await toggleWorkspaceErrorHandler( + errorHandlerKind, + scriptOrFlowPath, + errorHandlerMuted + ) + if (next !== undefined) errorHandlerMuted = next + } + + const allMenuItems: Item[] = $derived([ + ...(wide.current ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({ + displayName: b.label, + description: b.description, + icon: b.buttonProps.startIcon, + href: b.buttonProps.href, + action: b.buttonProps.onClick, + disabled: b.buttonProps.disabled, + type: 'action' as const + })), + ...(wide.current + ? [] + : [ + { + displayName: errorHandlerMuted ? 'Unmute error handler' : 'Mute error handler', + icon: errorHandlerMuted ? BellOff : Bell, + action: toggleErrorHandler, + type: 'action' as const + } + ]), + ...menuItems.map((item, i) => ({ + displayName: item.label, + icon: item.Icon, + action: item.onclick, + type: item.color === 'red' ? ('delete' as const) : ('action' as const), + separatorTop: i === 0 && !wide.current + })) + ]) + + function dropdownItemsOf(host: MainButton) { + if (wide.current) return undefined + const items = mainButtons + .filter((b) => dropdownHost(b) === host) + .map((b) => ({ + label: b.label, + description: b.description, + icon: b.buttonProps.startIcon, + href: b.buttonProps.href, + onClick: b.buttonProps.onClick, + disabled: b.buttonProps.disabled + })) + return items.length > 0 ? items : undefined + }
@@ -103,38 +176,26 @@ {@render trigger_badges?.()}
- {#if menuItems.length > 0} - {#key menuItems} - ({ - displayName: item.label, - icon: item.Icon, - action: item.onclick, - type: item.color === 'red' ? 'delete' : 'action' - }))} - placement="bottom-end" - size="md" - /> + {#if allMenuItems.length > 0} + {#key allMenuItems} + {/key} {/if} - - {#each mainButtons as btn} + {#if wide.current} + + {/if} + {#each barButtons as btn (btn.label)} + {@const dropdownItems = dropdownItemsOf(btn)} - 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/lib/components/details/ErrorHandlerToggleButton.svelte b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte index 9c0b964c9f..6097a235ca 100644 --- a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte +++ b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte @@ -2,58 +2,21 @@ import { Bell, BellOff } from 'lucide-svelte' import { Button } from '$lib/components/common' - import { FlowService, ScriptService } from '$lib/gen' - import { sendUserToast } from '$lib/toast' - import { workspaceStore } from '$lib/stores' import Tooltip from '../Tooltip.svelte' + import { toggleWorkspaceErrorHandler } from './errorHandlerToggle' interface Props { - kind: 'script' | 'flow'; - scriptOrFlowPath: string; - errorHandlerMuted: boolean | undefined; - iconOnly?: boolean; + kind: 'script' | 'flow' + scriptOrFlowPath: string + errorHandlerMuted: boolean | undefined + iconOnly?: boolean } - let { - kind, - scriptOrFlowPath, - errorHandlerMuted = $bindable(), - iconOnly = true - }: Props = $props(); + let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props() async function toggleErrorHandler(): Promise { - if ($workspaceStore !== undefined) { - try { - if (kind === 'flow') { - await FlowService.toggleWorkspaceErrorHandlerForFlow({ - workspace: $workspaceStore, - path: scriptOrFlowPath, - requestBody: { - muted: !errorHandlerMuted - } - }) - } else { - await ScriptService.toggleWorkspaceErrorHandlerForScript({ - workspace: $workspaceStore, - path: scriptOrFlowPath, - requestBody: { - muted: !errorHandlerMuted - } - }) - } - } catch (error) { - sendUserToast( - `Error while toggling Workspace Error Handler: ${error.body || error.message}`, - true - ) - return - } - errorHandlerMuted = !errorHandlerMuted - sendUserToast( - errorHandlerMuted ? 'Workspace error handler muted' : 'Workspace error handler active', - false - ) - } + const next = await toggleWorkspaceErrorHandler(kind, scriptOrFlowPath, errorHandlerMuted) + if (next !== undefined) errorHandlerMuted = next } diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 72e6cc5872..274cd1c173 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -82,6 +82,7 @@ import { buildForkEditUrl, editInForkAllowed, + editInForkDescription, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork' @@ -330,6 +331,8 @@ if (flow && !$userStore?.operator) { buttons.push({ label: 'Fork', + description: `Start a new flow from a copy of this one`, + narrow: { dropdownOf: 'Edit' }, buttonProps: { href: `${base}/flows/add?template=${flow.path}`, onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`), @@ -349,6 +352,8 @@ ) { buttons.push({ label: editInForkLabel(workspace, $userWorkspaces), + description: editInForkDescription('flow', workspace, $userWorkspaces), + narrow: { dropdownOf: 'Edit' }, buttonProps: { href: buildForkEditUrl('flow', flow.path), onClick: (e: Event | undefined) => @@ -377,6 +382,7 @@ buttons.push({ label: `History`, + narrow: 'menu', buttonProps: { onClick: () => flowHistory?.open(), unifiedSize: 'md', @@ -392,6 +398,7 @@ if (!$userStore?.operator) { buttons.push({ label: 'Build app', + narrow: 'menu', buttonProps: { onClick: async () => { const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema) @@ -519,6 +526,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(routePath ?? '') @@ -583,6 +591,7 @@ {/if} { - rightPaneSelected = 'triggers' + detailLayout?.showTriggers() }} {mainButtons} menuItems={getMenuItems(flow, deployUiSettings)} @@ -633,7 +642,7 @@ isFlow selected={rightPaneSelected == 'triggers'} onSelect={async (triggerIndex: number) => { - rightPaneSelected = 'triggers' + detailLayout?.showTriggers() await tick() triggersState.selectedTriggerIndex = triggerIndex }} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 835bb6fdd0..bb831bd537 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -104,6 +104,7 @@ import { buildForkEditUrl, editInForkAllowed, + editInForkDescription, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork' @@ -493,6 +494,8 @@ if (!topHash && script && !$userStore?.operator && !script.codebase) { buttons.push({ label: 'Fork', + description: `Start a new script from a copy of this one`, + narrow: { dropdownOf: 'Edit' }, buttonProps: { href: `${base}/scripts/add?template=${script.path}`, onClick: interceptNav(onNavigate, `/scripts/add?template=${script.path}`), @@ -512,6 +515,8 @@ ) { buttons.push({ label: editInForkLabel(workspace, $userWorkspaces), + description: editInForkDescription('script', workspace, $userWorkspaces), + narrow: { dropdownOf: 'Edit' }, buttonProps: { href: buildForkEditUrl('script', script.path), onClick: (e: Event | undefined) => @@ -545,6 +550,7 @@ if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) { buttons.push({ label: `History`, + narrow: 'menu', buttonProps: { onClick: () => { versionsDrawerOpen = !versionsDrawerOpen @@ -560,6 +566,7 @@ if (!$userStore?.operator) { buttons.push({ label: 'Build app', + narrow: 'menu', buttonProps: { onClick: async () => { const app = createRawAppFromScript(script.path, script.summary, script.schema) diff --git a/frontend/src/lib/components/details/errorHandlerToggle.ts b/frontend/src/lib/components/details/errorHandlerToggle.ts new file mode 100644 index 0000000000..1a7ab5296a --- /dev/null +++ b/frontend/src/lib/components/details/errorHandlerToggle.ts @@ -0,0 +1,39 @@ +import { get } from 'svelte/store' +import { FlowService, ScriptService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { workspaceStore } from '$lib/stores' + +/** Flips the workspace error handler for one script or flow and reports the outcome in a + * toast. Returns the new muted state, or undefined when nothing changed. */ +export async function toggleWorkspaceErrorHandler( + kind: 'script' | 'flow', + path: string, + muted: boolean | undefined +): Promise { + const workspace = get(workspaceStore) + if (workspace === undefined) return undefined + const next = !muted + try { + if (kind === 'flow') { + await FlowService.toggleWorkspaceErrorHandlerForFlow({ + workspace, + path, + requestBody: { muted: next } + }) + } else { + await ScriptService.toggleWorkspaceErrorHandlerForScript({ + workspace, + path, + requestBody: { muted: next } + }) + } + } catch (error) { + sendUserToast( + `Error while toggling Workspace Error Handler: ${error.body || error.message}`, + true + ) + return undefined + } + sendUserToast(next ? 'Workspace error handler muted' : 'Workspace error handler active', false) + return next +} diff --git a/frontend/src/lib/components/flows/ChatFlowBadge.svelte b/frontend/src/lib/components/flows/ChatFlowBadge.svelte new file mode 100644 index 0000000000..1787528269 --- /dev/null +++ b/frontend/src/lib/components/flows/ChatFlowBadge.svelte @@ -0,0 +1,20 @@ + + + + Chat + diff --git a/frontend/src/lib/components/flows/agentFormFields.test.ts b/frontend/src/lib/components/flows/agentFormFields.test.ts index 98c630cd70..311b48ade1 100644 --- a/frontend/src/lib/components/flows/agentFormFields.test.ts +++ b/frontend/src/lib/components/flows/agentFormFields.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' -import { AI_AGENT_SCHEMA } from './flowInfers' +import { AI_AGENT_SCHEMA, memoryOptionLabel, memoryPropertyFor } from './flowInfers' import { AGENT_FIELD_BY_KEY, AGENT_FIELDS, + agentMemoryMode, + historyInputApplies, agentFieldIsSet, initialVisibleAgentFields } from './agentFormFields' @@ -79,7 +81,69 @@ describe('initialVisibleAgentFields', () => { }) it('covers every schema key, so no field can only be reached through the raw doc', () => { - const registered = new Set(AGENT_FIELDS.map((f) => f.key)) + const registered = new Set(AGENT_FIELDS.map((f) => f.key)) expect(Object.keys(schemaProperties).filter((k) => !registered.has(k))).toEqual([]) }) }) + +describe('historyInputApplies', () => { + // Mirrors the worker: offering a step input a run would ignore misleads the author. + it('offers each history input in its own memory mode, and neither on an older setting', () => { + expect(agentMemoryMode(undefined)).toBe('off') + expect(agentMemoryMode({ kind: 'window', context_length: 0 })).toBe('off') + expect(agentMemoryMode({ kind: 'window', context_length: 10 })).toBe('managed') + expect(agentMemoryMode({ kind: 'manual', messages: [] })).toBe('legacy') + expect(agentMemoryMode({ kind: 'auto', context_length: 4, memory_id: 'x' })).toBe('legacy') + expect(agentMemoryMode({ kind: 'auto' })).toBe('off') + expect(historyInputApplies('memory_id', 'managed')).toBe(true) + expect(historyInputApplies('previous_messages', 'managed')).toBe(false) + expect(historyInputApplies('memory_id', 'off')).toBe(false) + expect(historyInputApplies('previous_messages', 'off')).toBe(true) + expect(historyInputApplies('memory_id', 'legacy')).toBe(false) + expect(historyInputApplies('previous_messages', 'legacy')).toBe(false) + expect(historyInputApplies('previous_messages', undefined)).toBe(true) + }) +}) + +describe('memoryOptionLabel', () => { + // The ignored-input note names the setting by the same label its own button carries. + it('names each memory option the way the field renders it', () => { + expect(memoryOptionLabel({ kind: 'manual', messages: [] })).toBe('Previous messages (legacy)') + expect(memoryOptionLabel({ kind: 'auto', context_length: 4 })).toBe('On (legacy)') + expect(memoryOptionLabel({ kind: 'window', context_length: 10 })).toBe('On') + // Keeping no messages runs as off, whichever kind says so. + expect(memoryOptionLabel({ kind: 'window', context_length: 0 })).toBe('Off') + expect(memoryOptionLabel({ kind: 'auto' })).toBe('Off') + expect(memoryOptionLabel(undefined)).toBeUndefined() + }) +}) + +describe('memoryPropertyFor', () => { + const property = schemaProperties.memory + const kinds = (value: unknown) => + memoryPropertyFor(property, value).oneOf.map((variant: { title: string }) => variant.title) + + it('adds a legacy kind as an option only while the value holds it', () => { + expect(memoryPropertyFor(property, { kind: 'window', context_length: 10 })).toBe(property) + expect(memoryPropertyFor(property, undefined)).toBe(property) + expect(kinds({ kind: 'auto', context_length: 4, memory_id: 'x' })).toEqual([ + 'off', + 'window', + 'auto' + ]) + expect(kinds({ kind: 'manual', messages: [] })).toEqual(['off', 'window', 'manual']) + const autoVariant = (value: unknown) => memoryPropertyFor(property, value).oneOf.at(-1) + expect(autoVariant({ kind: 'auto', context_length: 4 }).properties.memory_id).toBeUndefined() + expect( + autoVariant({ kind: 'auto', context_length: 4, memory_id: 'x' }).properties.memory_id + ).toBeDefined() + // A chat flow drops the baked id on save, so the form does not offer it there. + expect( + memoryPropertyFor( + property, + { kind: 'auto', context_length: 4, memory_id: 'x' }, + true + ).oneOf.at(-1).properties.memory_id + ).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/flows/agentFormFields.ts b/frontend/src/lib/components/flows/agentFormFields.ts index cce9584007..3d62497ba3 100644 --- a/frontend/src/lib/components/flows/agentFormFields.ts +++ b/frontend/src/lib/components/flows/agentFormFields.ts @@ -1,5 +1,5 @@ import { deepEqual } from 'fast-equals' -import type { InputTransform } from '$lib/gen' +import type { InputTransform, MemoryConfig } from '$lib/gen' /** * How the AI agent form presents `AI_AGENT_SCHEMA`: which group a field belongs to, what it is @@ -25,6 +25,54 @@ export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string }[] = [ * It lives in the registry so the groups keep a single ordering. */ export const AGENT_TOOLS_ROW = 'tools' +/** A step's own history inputs. Never seeded with a placeholder: a run reads a present key as the + * step's choice, so only the author adds them. */ +export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const +export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number] + +/** What turning managed memory on writes. */ +export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'window', context_length: 10 } + +/** The docs section on how an agent's memory is named and kept. */ +export const AGENT_MEMORY_DOCS_URL = + 'https://www.windmill.dev/docs/core_concepts/ai_agents#memory-auto--manual' + +/** Whether Windmill stores and replays the agent's conversation, mirroring the worker: `window`, or + * its older spelling `auto`, with a message count above 0. A legacy `manual` list is not managed. */ +export function keepsManagedMemory(memory: any): boolean { + return (memory?.kind === 'window' || memory?.kind === 'auto') && Boolean(memory.context_length) +} + +export type AgentMemoryMode = 'legacy' | 'managed' | 'off' + +/** Which shape a run reads this memory as: an older `auto`/`manual` setting, or the current one. */ +export function agentMemoryMode(memory: any): AgentMemoryMode { + if (memory?.kind === 'manual') return 'legacy' + // The worker reads an `auto` that keeps no messages as off, history inputs included, so the form + // offers what that run would read. + if (memory?.kind === 'auto') return memory.context_length ? 'legacy' : 'off' + return keepsManagedMemory(memory) ? 'managed' : 'off' +} + +/** Whether a run reads this step input, mirroring the worker: managed memory reads only a memory + * id, memory that is off only previous messages, and an older setting neither. A setting the form + * cannot read yet leaves both open. */ +export function historyInputApplies( + key: AgentHistoryKey, + mode: AgentMemoryMode | undefined +): boolean { + if (mode === undefined) return true + if (mode === 'legacy') return false + return (key === 'memory_id') === (mode === 'managed') +} + +/** A memory setting in words, for a linked agent's summary. */ +export function describeMemoryPolicy(memory: any): string { + if (keepsManagedMemory(memory)) return `Last ${memory.context_length} messages` + if (memory?.kind === 'manual') return 'Off, sends previous messages saved with the agent' + return 'Off' +} + export interface AgentFieldSpec { key: string group: AgentFieldGroup @@ -76,6 +124,14 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [ tooltip: 'The most tokens the model may produce in its answer.', defaultHint: 'Default: the provider decides' }, + { + key: 'user_message', + group: 'messages', + label: 'User message', + tooltip: + "The user turn, sent after the system message and any history. Turn on chat input on the flow's input interface to feed it from the chat.", + core: true + }, { key: 'system_prompt', group: 'messages', @@ -86,20 +142,29 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [ { key: 'memory', group: 'messages', - label: 'Memory', - tooltip: - 'History sent between the system message and the user message. Windmill can keep it for you, or you can supply the messages yourself.', + label: 'Managed memory', + tooltip: 'Windmill stores the conversation and sends its last messages with each request.', implicit: { kind: 'off' }, defaultHint: 'Default: off', textOnly: true }, { - key: 'user_message', + key: 'memory_id', group: 'messages', - label: 'User message', + label: 'Memory id', tooltip: - "The user turn, sent after the system message and any history. Turn on chat input on the flow's input interface to feed it from the chat.", - core: true + 'Conversation history id: runs with the same id share their history. Inherited uses the memory_id the run was started with: the conversation id in chat mode, or the memory_id query parameter otherwise. Without either, each run starts fresh. Custom sets the id on the step: a fixed id shares one history across all runs, an expression keeps one history per value.', + implicit: '', + textOnly: true + }, + { + key: 'previous_messages', + group: 'messages', + label: 'Previous messages', + tooltip: 'History the flow supplies, sent between the system message and the user message.', + implicit: [], + defaultHint: 'Default: none', + textOnly: true }, { key: 'user_attachments', diff --git a/frontend/src/lib/components/flows/agentResourceUtils.test.ts b/frontend/src/lib/components/flows/agentResourceUtils.test.ts index 92d0b13385..96b9cf83ff 100644 --- a/frontend/src/lib/components/flows/agentResourceUtils.test.ts +++ b/frontend/src/lib/components/flows/agentResourceUtils.test.ts @@ -77,7 +77,7 @@ describe('summarizeAgentBrain', () => { output_schema: { type: 'object' } as any }) expect(rows).toEqual([ - { label: 'Memory', value: 'auto' }, + { label: 'Managed memory', value: 'Last 20 messages' }, { label: 'Output schema', value: 'configured' } ]) }) @@ -147,8 +147,11 @@ describe('flowLocalInputs', () => { expect( flowLocalInputs({ provider: { type: 'static', value: {} }, + memory: { type: 'static', value: { kind: 'window', context_length: 10 } }, user_message: { type: 'static', value: 'hi' }, user_attachments: { type: 'static', value: [] }, + memory_id: { type: 'javascript', expr: 'flow_input.customer_id' }, + previous_messages: { type: 'static', value: [{ role: 'user', content: 'earlier' }] }, // The roster it narrows belongs to the agent, but which of it one flow may call does // not: saving this into the resource would impose it on every flow linking the agent. enabled_tools: { type: 'javascript', expr: 'flow_input.tools' } @@ -156,6 +159,8 @@ describe('flowLocalInputs', () => { ).toEqual({ user_message: { type: 'static', value: 'hi' }, user_attachments: { type: 'static', value: [] }, + memory_id: { type: 'javascript', expr: 'flow_input.customer_id' }, + previous_messages: { type: 'static', value: [{ role: 'user', content: 'earlier' }] }, enabled_tools: { type: 'javascript', expr: 'flow_input.tools' } }) }) diff --git a/frontend/src/lib/components/flows/agentResourceUtils.ts b/frontend/src/lib/components/flows/agentResourceUtils.ts index 3d582fa7c0..3ee0eaa77e 100644 --- a/frontend/src/lib/components/flows/agentResourceUtils.ts +++ b/frontend/src/lib/components/flows/agentResourceUtils.ts @@ -1,6 +1,6 @@ import { deepEqual } from 'fast-equals' import type { InputTransform } from '$lib/gen' -import { AGENT_FIELDS } from './agentFormFields' +import { AGENT_FIELDS, describeMemoryPolicy } from './agentFormFields' // The brain fields stored flat in an `ai_agent` resource value. The flow-local inputs below are // intentionally excluded — they are supplied per-flow. @@ -20,9 +20,16 @@ export const AGENT_BRAIN_KEYS = [ * The inputs a step supplies for itself, whether or not it is linked to a saved agent. * * `enabled_tools` is one of them because it narrows one use of an agent rather than the agent: - * saving it into the resource would impose one flow's roster on every flow linking it. + * saving it into the resource would impose one flow's roster on every flow linking it. The history + * inputs are too, since which conversation a step reads belongs to the flow using the agent. */ -export const AGENT_FLOW_LOCAL_KEYS = ['user_message', 'user_attachments', 'enabled_tools'] as const +export const AGENT_FLOW_LOCAL_KEYS = [ + 'user_message', + 'user_attachments', + 'enabled_tools', + 'memory_id', + 'previous_messages' +] as const export type AgentTool = Record @@ -177,8 +184,7 @@ export function summarizeAgentBrain( if (key === 'provider') { value = [v.kind, v.model].filter(Boolean).join(' · ') || 'configured' } else if (key === 'memory') { - // Memory configs are serialized with a `kind` tag (serde tag = "kind"). - value = typeof v === 'object' ? (v.kind ?? v.type ?? 'configured') : String(v) + value = typeof v === 'object' ? describeMemoryPolicy(v) : String(v) } else if (key === 'output_schema') { value = 'configured' } else if (typeof v === 'boolean') { diff --git a/frontend/src/lib/components/flows/agentToolUtils.ts b/frontend/src/lib/components/flows/agentToolUtils.ts index 3e0df9c7bd..e9838f60e3 100644 --- a/frontend/src/lib/components/flows/agentToolUtils.ts +++ b/frontend/src/lib/components/flows/agentToolUtils.ts @@ -1,3 +1,4 @@ +import { AGENT_HISTORY_KEYS } from './agentFormFields' import type { AiAgent, FlowModule, FlowModuleValue, InputTransform } from '$lib/gen' import { loadStoredConfig } from '../aiProviderStorage' import { AI_AGENT_SCHEMA } from './flowInfers' @@ -138,7 +139,7 @@ export function createAiAgentTool(id: string): AiAgentTool { user_message: { type: 'ai' } } for (const key of Object.keys(AI_AGENT_SCHEMA.properties ?? {})) { - if (!(key in input_transforms)) { + if (!(key in input_transforms) && !(AGENT_HISTORY_KEYS as readonly string[]).includes(key)) { ;(input_transforms as Record)[key] = { type: 'static', value: undefined diff --git a/frontend/src/lib/components/flows/content/AgentMemoryNotes.svelte b/frontend/src/lib/components/flows/content/AgentMemoryNotes.svelte new file mode 100644 index 0000000000..491a26daa3 --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentMemoryNotes.svelte @@ -0,0 +1,152 @@ + + +{#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 3456b4ead6..83b2065fcd 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -26,7 +26,8 @@ Save, X, Check, - Settings2 + Settings2, + MessageSquare } from 'lucide-svelte' import CaptureIcon from '$lib/components/triggers/CaptureIcon.svelte' import FlowInputEditor from './FlowInputEditor.svelte' @@ -47,9 +48,13 @@ import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen' import { deepEqual } from 'fast-equals' import Toggle from '$lib/components/Toggle.svelte' + 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 ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import { fetchAgentWithDraft, normalizeAgentRef } from '../linkedAgentDrafts' + import type { AIAgentConfig } from '../agentResourceUtils' import FlowChat from '../conversations/FlowChat.svelte' import { SPECIAL_MODULE_IDS } from '$lib/components/copilot/chat/shared' @@ -96,9 +101,10 @@ ) let chatInputEnabled = $state(Boolean(flowStore.val.value?.chat_input_enabled)) - let showChatModeWarning = $state(false) - let showAdditionalInputs = $state(false) + // Chat mode shows one of the two at a time: the conversation, or the inputs it sends. + let chatPanelTab = $state<'chat' | 'inputs'>('chat') let chatInputsEditTab = $state(false) + let chatEditableSchemaForm: EditableSchemaForm | undefined = $state(undefined) let chatInputsAddPropertyV2: AddPropertyV2 | undefined = $state(undefined) let addPropertyV2: AddPropertyV2 | undefined = $state(undefined) @@ -511,23 +517,9 @@ return jobId } - function hasOtherInputs(): boolean { - const properties = flowStore.val.schema?.properties - return Boolean( - properties && - Object.keys(properties).length > 0 && - !(Object.keys(properties).length === 1 && Object.keys(properties).includes('user_message')) - ) - } - function handleToggleChatMode() { if (!flowStore.val.value?.chat_input_enabled) { - // Check if there are existing inputs - if (hasOtherInputs()) { - showChatModeWarning = true - } else { - enableChatMode() - } + enableChatMode() } else { // Disable chat input - remove from flow.value if (flowStore.val.value) { @@ -536,28 +528,84 @@ } } + /** + * Add the flow input the agent's `user_attachments` reads, and return its name. Chat + * mode means files dropped in the composer, and that only works through a flow input — + * so it is set up with the message and the memory rather than left to be discovered. + */ + function addAttachmentsInput(): string { + const schema = (flowStore.val.schema ?? {}) as Record + const properties: Record = (schema.properties ??= {}) + let name = 'files' + for (let i = 2; name in properties; i++) name = `files_${i}` + properties[name] = { + type: 'array', + items: { type: 'object', resourceType: 's3object' }, + description: 'Images or PDFs for the agent to read' + } + flowStore.val.schema = schema + return name + } + + // The step panel seeds an array-typed field with [] and undefined persists as null + // through JSON round-trips, so both count as nothing configured. + function isEmptyAgentChatInputValue(value: unknown): boolean { + return value == null || value === '' || (Array.isArray(value) && value.length === 0) + } + + /** + * Memory and streaming of a linked agent are the resource's, so enabling chat mode cannot + * set them. Read the agent, its draft first, and say what the chat will lack. + */ + async function warnIfLinkedAgentCannotChat(path: string) { + if (!opWs) return + let args: AIAgentConfig + try { + const { response, draft } = await fetchAgentWithDraft(path, opWs) + args = draft?.args ?? ((response.value ?? {}) as AIAgentConfig) + } catch { + // Unreadable here means unreadable at run time too; that run reports it. + return + } + const missing: string[] = [] + if (!keepsManagedMemory(args.memory)) missing.push('memory') + if (args.streaming !== true) missing.push('streaming') + if (missing.length > 0) { + sendUserToast( + `The linked agent ${path} has ${missing.join(' and ')} off. The chat needs both; turn them on in the agent.`, + true + ) + } + } + function enableChatMode() { // Enable chat input - set in flow.value flowStore.val.value.chat_input_enabled = true - // Set up the schema for chat input + // The chat fills the flow's form rather than standing in for it: all it needs is a + // `user_message` string, which the server requires under that exact argument name. + // Every other input stays — the composer drives the ones an agent field reads, and + // the rest are asked for in the Configure-inputs modal. + const schema = flowStore.val.schema ?? {} + const properties = { ...(schema.properties ?? {}) } + // Only a string can carry the message; anything else here cannot be what chat sends. + if (properties['user_message']?.type !== 'string') { + properties['user_message'] = { type: 'string', description: 'Message from user' } + } + const required: string[] = Array.isArray(schema.required) ? schema.required : [] flowStore.val.schema = { $schema: 'https://json-schema.org/draft/2020-12/schema', + ...schema, type: 'object', - properties: { - user_message: { - type: 'string', - description: 'Message from user' - } - }, - required: ['user_message'] + properties, + required: required.includes('user_message') ? required : [...required, 'user_message'] } // Find all AI agent modules 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, @@ -570,9 +618,13 @@ (accu, key) => { if (key === 'user_message') { accu[key] = { type: 'javascript', expr: 'flow_input.user_message' } + } 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 } } - } else { + accu[key] = { type: 'static', value: structuredClone(DEFAULT_AGENT_MEMORY) } + } else if (key === 'streaming') { + accu[key] = { type: 'static', value: true } + } else if (!(AGENT_HISTORY_KEYS as readonly string[]).includes(key)) { accu[key] = { type: 'static', value: undefined @@ -586,25 +638,23 @@ } ] sendUserToast( - 'Chat mode enabled. AI agent created with user message input and context memory set to 10.', + '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) { // Exactly one AI agent exists: fill in defaults only for inputs the // user hasn't configured, so re-enabling chat mode on an already - // configured agent doesn't clobber a custom user_message expression - // or a deliberate memory choice (e.g. off). + // configured agent doesn't clobber a custom user_message expression. const aiAgent = aiAgentModules[0] const value = aiAgent.value as AiAgent // Degenerate shapes the input form can produce without deliberate - // configuration count as unconfigured: empty static value (undefined - // persists as null through JSON round-trips), blank JS expression - // (the JS toggle seeds a bare backtick pair), or an AI transform - // (meaningless for the chat input). + // configuration count as unconfigured: empty static value, blank JS + // expression (the JS toggle seeds a bare backtick pair), or an AI + // transform (meaningless for the chat input). const isUnconfigured = (transform: InputTransform | undefined) => transform === undefined || - (transform.type === 'static' && (transform.value == null || transform.value === '')) || + (transform.type === 'static' && isEmptyAgentChatInputValue(transform.value)) || (transform.type === 'javascript' && transform.expr.replaceAll('`', '').trim() === '') || transform.type === 'ai' @@ -617,12 +667,54 @@ applied.push('user message input') } - if (isUnconfigured(value.input_transforms['memory'])) { - value.input_transforms['memory'] = { - type: 'static', - value: { kind: 'auto', context_length: 10 } + if (isUnconfigured(value.input_transforms['user_attachments'])) { + value.input_transforms['user_attachments'] = { + type: 'javascript', + expr: `flow_input.${addAttachmentsInput()}` + } + applied.push('attachments input') + } + + // A linked step's brain lives in the agent resource: the worker overlays only the + // flow-local keys from the step, so a memory or streaming transform written here + // 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, 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 !== '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 ( + !suppliesHistory && + (isUnconfigured(value.input_transforms['memory']) || + memoryIsOff(value.input_transforms['memory'])) + ) { + value.input_transforms['memory'] = { + type: 'static', + value: structuredClone(DEFAULT_AGENT_MEMORY) + } + applied.push('managed memory on') + } + + // Without streaming the chat has no SSE to read, so a turn shows nothing — + // no thinking, no answer — until the run ends and its rows are written. + if ( + isUnconfigured(value.input_transforms['streaming']) || + (value.input_transforms['streaming']?.type === 'static' && + value.input_transforms['streaming'].value === false) + ) { + value.input_transforms['streaming'] = { type: 'static', value: true } + applied.push('streaming turned on') } - applied.push('context memory set to 10') } sendUserToast( @@ -631,42 +723,53 @@ : 'Chat mode enabled. Existing AI agent configuration kept unchanged.', false ) + if (linkedAgent !== undefined) { + warnIfLinkedAgentCannotChat(linkedAgent) + } } // If there are multiple AI agents, don't auto-configure (ambiguous which one to configure) - - showChatModeWarning = false } - { - showChatModeWarning = false - chatInputEnabled = false - }} -> -

- Enabling Chat Mode will replace all existing flow inputs with a single - user_message - parameter. -

-

- Your current input configuration will be lost. Are you sure you want to continue? -

-
+ +{#snippet inputsEditButton(open: boolean, toggle: () => void)} + + + {#snippet children({ item })} + + + {/snippet} + {/if}
{/if} @@ -696,11 +804,15 @@
{#if flowStore.val.value?.chat_input_enabled}
- {#if showAdditionalInputs} -
+ {#if chatPanelTab === 'inputs'} + +
{#snippet openEditTab()} - + {@render inputsAddTrigger()} {/snippet} {/snippet}
{/if} - + +
+ +
{:else}
@@ -815,22 +926,9 @@
{#snippet close_button()} -
@@ -884,12 +982,7 @@ }} > {#snippet trigger()} -
- -
+ {@render inputsAddTrigger()} {/snippet} {/if} 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/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index 160af2bf27..8608525241 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,19 @@ path: string hideSidebar?: boolean inputSchema?: Record + /** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring + * and the attachments input. */ + flowModules?: FlowModule[] + /** The flow's description, shown under the empty transcript's prompt. */ + description?: string + wideLayout?: boolean + /** + * What this surface's own runs are: the editor runs previews and lists its test + * chats, the flow page runs the deployed flow and lists only its users' chats. + * The sidebar offers the kind filter everywhere but on the deployed flow, whose + * users have no test chats to look at. + */ + conversationKind?: 'test' | 'deployed' } let { @@ -29,7 +43,11 @@ deploymentInProgress = false, path, hideSidebar = false, - inputSchema = undefined + inputSchema = undefined, + flowModules = undefined, + description = undefined, + wideLayout = false, + conversationKind = 'deployed' }: Props = $props() const flowEditorContext = getContext('FlowEditorContext') @@ -88,15 +106,28 @@
{#if chat && chatState} {#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..f4cdc64912 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -1,79 +1,60 @@ - -{#if additionalInputsSchema} +{#if modalSchema} @@ -164,82 +259,83 @@ {/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 modalSchema} +
+ + {#if modalMissingRequired} + + {/if} +
+ {/if} + {#if modelWiring && showModelButton} + + + {/if} +{/snippet} + + +
0} + class:min-h-64={chatHost.displayMessages.length === 0} +> + {}} + deletePastChat={() => {}} + saveAndClear={() => {}} + />
diff --git a/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte b/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte new file mode 100644 index 0000000000..f27e14749e --- /dev/null +++ b/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte @@ -0,0 +1,299 @@ + + +{#snippet providerSummary()} + {#if resourcePath} + {resourcePath} + {/if} +{/snippet} + +{#if resourceEditable} + { + resourcesVersion++ + if (e.detail) { + pendingResourcePath = e.detail + } + }} + /> +{/if} + + 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 @@