diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 62e0f39bee..09557c4e0c 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -671,9 +671,14 @@ pub async fn handle_chat_conversation_messages( job_id: Uuid, is_test: bool, ) -> error::Result<()> { + // Names the query parameter rather than the field: it is not a flow argument, and + // supplying it as one is the first thing tried on reading `memory_id is required`. let memory_id = run_query.memory_id.ok_or_else(|| { windmill_common::error::Error::BadRequest( - "memory_id is required for chat-enabled flows".to_string(), + "memory_id is required for chat-enabled flows. Pass it as the `memory_id` query \ + parameter, not as a flow argument: it names the conversation the turn belongs to, \ + so a fresh UUID starts one and reusing a UUID continues it." + .to_string(), ) })?; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index a8790f35a7..99e5c4abdb 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5252,9 +5252,51 @@ 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. + +**Wire the provider field by field, or the chat cannot change its model.** Each \`provider\` field fed +by a flow input becomes a control in the composer — a provider picker, a model list, a thinking +slider — while a field left static is fixed and shown read-only. \`user_attachments\` works the same +way: point it at an s3-object input and the composer gets a paperclip. + +\`\`\`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": "auto", "context_length": 10 } }, + "streaming": { "type": "static", "value": true }, + "output_type": { "type": "static", "value": "text" } + }, + "tools": [] + } +} +\`\`\` + +- \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing +- \`streaming\` on makes the answer and its thinking appear token by token instead of all at once +- 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 +- The provider expression must be 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 ### Tool Naming Rules diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 272bdb64bf..602fabc513 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2434,7 +2434,8 @@ export class AIChatManager implements ChatViewHost { openArtifact: this.openArtifact } : {}), - testActiveFlow: async (args?: Record) => this.flowAiChatHelpers?.testFlow(args), + testActiveFlow: async (args?: Record, conversationId?: string) => + this.flowAiChatHelpers?.testFlow(args, conversationId), getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined), attachedFiles: this.attachedFiles, getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '', diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 54856a016d..4050650a64 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -865,7 +865,9 @@ describe('AIChatManager autonomy mode', () => { const jobId = await manager.helpers.testActiveFlow({ name: 'Ada' }) expect(jobId).toBe('job-flow-preview') - expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }) + // Second argument is the chat-mode conversation id, which only `test_run_flow`'s + // own `conversation_id` supplies — never the session id. + expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }, undefined) }) }) diff --git a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte index 69a23df9e7..8fcc610e3a 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte @@ -6,7 +6,7 @@ */ import { User, Building2, Settings, ExternalLink } from 'lucide-svelte' import ChatModelSettings from '../ChatModelSettings.svelte' - import type { ChatModelSettingsConfig } from '../chatModelSettings' + import { carriedReasoning, type ChatModelSettingsConfig } from '../chatModelSettings' import { COPILOT_SESSION_MODEL_SETTING_NAME, COPILOT_SESSION_PROVIDER_SETTING_NAME, @@ -62,19 +62,15 @@ let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80) function selectModel(m: AIProviderModel) { - // Carry the effort onto the new model only if it supports that level ('off' - // only where the model can truly disable); otherwise drop it so the model's - // default applies. - const carried = providerModel.reasoning - const cap = getReasoningCapability(m.provider, m.model) - const keep = - carried === REASONING_OFF - ? cap.canDisable - : carried !== undefined && cap.levels.includes(carried) - $copilotSessionModel = { ...m, ...(keep ? { reasoning: carried } : {}) } + const keep = carriedReasoning( + providerModel.reasoning, + REASONING_OFF, + getReasoningCapability(m.provider, m.model) + ) + $copilotSessionModel = { ...m, ...(keep !== undefined ? { reasoning: keep } : {}) } storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model) storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider) - storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep ? carried : undefined) + storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep) } function selectReasoning(value: string) { diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index 47a6b0b169..79df21483e 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -1,4 +1,5 @@