feat(ai-chat): keep the thinking effort runnable, and let a test run name its conversation

- Picking a model that cannot think left the old `reasoning_effort` in the flow
  input and the run sent it anyway. The composer now writes the effort alongside
  the model — carried where the new model has that level, cleared where it does
  not — through `carriedReasoning`, the rule the session chat already applied
  inline and now shares.
- `reasoning_effort` no longer falls back to the Configure-inputs modal on a
  model that cannot reason. The popover always owns it once a model is chosen:
  the slider, or the row saying the model cannot think. On such a model the
  value is not a choice but a fact, so offering it in the modal only invited
  setting a level the provider rejects.
- `memory_id` names the conversation a chat turn belongs to and is a query
  parameter, so no caller could supply it through `args` — and the error said
  only that it was required. It now says where it goes, `test_run_flow` supplies
  one for a chat-enabled flow, and its new `conversation_id` lets a caller
  continue a conversation instead of always starting a fresh one, which is the
  only way to test that an agent's memory works.
- `flow-base.md` gains a Chat-Mode Flows section: wiring `provider` fields to
  flow inputs is what puts the model and thinking controls in the composer, and
  a fully static provider gives a chat that cannot change its model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QN7VboDEm9HAB1t4sMxMdE
This commit is contained in:
Guilhem Lemouel
2026-09-10 19:57:39 +02:00
co-authored by Claude Opus 5
parent 2c79a2f143
commit b40219652f
18 changed files with 419 additions and 92 deletions
+6 -1
View File
@@ -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(),
)
})?;
+44 -2
View File
@@ -5252,9 +5252,51 @@ tool, \`websearch\` for web search.
}
\`\`\`
- \`provider\` is a static object, not a bare resource string: \`{ "kind": <provider kind>,
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }\`. 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
@@ -2434,7 +2434,8 @@ export class AIChatManager implements ChatViewHost {
openArtifact: this.openArtifact
}
: {}),
testActiveFlow: async (args?: Record<string, any>) => this.flowAiChatHelpers?.testFlow(args),
testActiveFlow: async (args?: Record<string, any>, conversationId?: string) =>
this.flowAiChatHelpers?.testFlow(args, conversationId),
getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined),
attachedFiles: this.attachedFiles,
getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '',
@@ -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)
})
})
@@ -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) {
@@ -1,4 +1,5 @@
<script lang="ts">
import { randomUUID } from '$lib/utils/uuid'
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
import { getContext, tick, untrack } from 'svelte'
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
@@ -167,8 +168,14 @@
if (args) {
previewArgs.val = args
}
// A chat-enabled flow is refused without a conversation to run the turn in, and
// that id is a query parameter no caller can reach through `args`. A test run has
// no conversation open, so it gets one of its own rather than appending a turn to
// a chat someone is reading.
const memoryId =
conversationId ?? (flowStore.val.value.chat_input_enabled ? randomUUID() : undefined)
// Call the UI test function which opens preview panel
return await onTestFlow?.(conversationId)
return await onTestFlow?.(memoryId)
},
getLintErrors: async (moduleId: string): Promise<ScriptLintResult> => {
@@ -4842,12 +4842,57 @@ describe('global AI tools', () => {
)
)
expect(testActiveFlow).toHaveBeenCalledWith({ name: 'Ada' })
expect(testActiveFlow).toHaveBeenCalledWith({ name: 'Ada' }, 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 conversation 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: {},
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' },
conversation_id: '550e8400-e29b-41d4-a716-446655440000'
},
toolCallbacks,
{ testActiveFlow }
)
)
expect(testActiveFlow).toHaveBeenCalledWith(
{ user_message: 'hi' },
'550e8400-e29b-41d4-a716-446655440000'
)
})
it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => {
seedBackendDraft(
'flow',
@@ -4884,7 +4929,7 @@ describe('global AI tools', () => {
)
)
expect(testActiveFlow).toHaveBeenCalledWith({ name: 'Ada' })
expect(testActiveFlow).toHaveBeenCalledWith({ name: 'Ada' }, undefined)
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
@@ -1,3 +1,4 @@
import { randomUUID } from '$lib/utils/uuid'
import {
AppService,
AzureTriggerService,
@@ -920,6 +921,12 @@ const runScriptToolDef = createToolDef(
const testRunFlowSchema = z.object({
path: z.string().describe('Workspace path of the flow to test.'),
args: testRunArgsSchema,
conversation_id: z
.string()
.optional()
.describe(
'Chat-mode flows only. A UUID naming the conversation this turn belongs to: 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
})
@@ -4367,7 +4374,12 @@ type WriteDraftCtx = {
export type SessionToolHelpers = { sessionId?: string }
export type GlobalToolHelpers = SessionToolHelpers & {
testActiveFlow?: (args?: Record<string, any>) => Promise<string | undefined>
/** `conversationId` names the chat-mode conversation the turn belongs to; the editor
* mints one when it is omitted and the flow is chat-enabled. */
testActiveFlow?: (
args?: Record<string, any>,
conversationId?: string
) => Promise<string | undefined>
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
@@ -5409,6 +5421,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.
*/
function chatMemoryId(value: FlowValue): string | undefined {
return value.chat_input_enabled ? randomUUID() : undefined
}
async function loadScriptForFlowStep(
moduleValue: { path: string; hash?: string },
workspace: string
@@ -5780,19 +5802,17 @@ async function testRunFlowByPath(
if (testActiveFlow) {
return executeTestRun({
jobStarter: async () => {
const jobId = await testActiveFlow(testArgs)
const jobId = await testActiveFlow(testArgs, args.conversation_id)
if (jobId) {
return jobId
}
const flow = await loadFlowDraftValue(args.path, workspace)
const value = flowDraftValueForPreview(flow.flow)
return JobService.runFlowPreview({
workspace,
requestBody: {
path: args.path,
value: flowDraftValueForPreview(flow.flow),
args: testArgs
}
memoryId: args.conversation_id ?? chatMemoryId(value),
requestBody: { path: args.path, value, args: testArgs }
})
},
workspace,
@@ -5809,15 +5829,14 @@ async function testRunFlowByPath(
const flow = await loadFlowDraftValue(args.path, workspace)
return executeTestRun({
jobStarter: () =>
JobService.runFlowPreview({
jobStarter: () => {
const value = flowDraftValueForPreview(flow.flow)
return JobService.runFlowPreview({
workspace,
requestBody: {
path: args.path,
value: flowDraftValueForPreview(flow.flow),
args: testArgs
}
}),
memoryId: args.conversation_id ?? chatMemoryId(value),
requestBody: { path: args.path, value, args: testArgs }
})
},
workspace,
toolCallbacks,
toolId,
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
carriedReasoning,
reasoningDisplay,
REASONING_PROVIDER_DEFAULT,
type ChatModelSettingsReasoning
@@ -114,3 +115,28 @@ describe('reasoningDisplay', () => {
expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT)
})
})
describe('carriedReasoning', () => {
const cap = (model: string) => getReasoningCapability('openai', model)
// The bug this exists for: picking a model that cannot think left the old level in the
// flow input, and the run sent it anyway.
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()
})
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()
})
})
@@ -79,6 +79,24 @@ export type ChatModelSettingsReasoning = {
/** 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.
*/
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.
@@ -8,8 +8,7 @@
import Modal from '$lib/components/common/modal/Modal.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { type DynamicInput } from '$lib/utils'
import { type AIProvider, type FlowModule } from '$lib/gen'
import { getReasoningCapability } from '$lib/components/copilot/reasoningRegistry'
import { type FlowModule } from '$lib/gen'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import { workspaceStore } from '$lib/stores'
import FlowChatModelSettings from './FlowChatModelSettings.svelte'
@@ -102,20 +101,6 @@
...inputValues
})
// Whether the model button will offer the thinking slider, which it does only for a
// model that reasons. A provider kind or model we cannot read leaves it unknown, and
// the field then stays in the modal rather than behind a control that never appears.
const effortEditable = $derived.by(() => {
if (!modelWiring || modelWiring.whole) return false
const pick = (field: 'model' | 'kind') => {
const name = modelWiring.fields[field]
return name ? effectiveInputs[name] : modelWiring.fixed[field]
}
const [kind, model] = [pick('kind'), pick('model')]
if (typeof kind !== 'string' || !kind || typeof model !== 'string' || !model) return false
return getReasoningCapability(kind as AIProvider, model).supported
})
function getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}${path}`
}
@@ -159,7 +144,7 @@
attachmentsTarget: () => attachmentsTarget,
workspace: () => chatWorkspace,
canAttach: () => workspaceStorage.current,
inputsShownInComposer: () => agentModelWiringInputs(modelWiring, effortEditable),
inputsShownInComposer: () => agentModelWiringInputs(modelWiring),
inputsSchema: () => additionalInputsSchema
})
setChatViewHost(chatHost)
@@ -177,7 +162,7 @@
// The paperclip's own condition, not half of it: an attachments input the composer
// has no editor for — no object storage in the workspace, say — stays askable here.
...(chatHost.supportsMessageAttachments && attachmentsTarget ? [attachmentsTarget.name] : []),
...agentModelWiringInputs(modelWiring, effortEditable)
...agentModelWiringInputs(modelWiring)
])
const properties = Object.fromEntries(
Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => !promoted.has(key))
@@ -9,10 +9,16 @@
* offers a knob whose value it could not write back.
*/
import ChatModelSettings from '$lib/components/copilot/ChatModelSettings.svelte'
import type { ChatModelSettingsConfig } from '$lib/components/copilot/chatModelSettings'
import {
carriedReasoning,
type ChatModelSettingsConfig
} from '$lib/components/copilot/chatModelSettings'
import AppConnect from '$lib/components/AppConnectDrawer.svelte'
import { AI_PROVIDERS, fetchAvailableModels } from '$lib/components/copilot/lib'
import { explicitOffToken } from '$lib/components/copilot/reasoningRegistry'
import {
explicitOffToken,
getReasoningCapability
} from '$lib/components/copilot/reasoningRegistry'
import { ResourceService, type AIProvider } from '$lib/gen'
import type { Item } from '$lib/utils'
import { Plug, Plus } from 'lucide-svelte'
@@ -142,13 +148,32 @@
}
})
/**
* The effort to write alongside a new model, which is `''` — the agent's "no effort" —
* wherever that model has no such level. The composer owns this input, so it keeps it
* runnable rather than leaving behind a level the provider would reject; the slider
* shows the model cannot think, and the reader has nothing to clear by hand.
*/
function effortFor(nextModel: string | undefined): string {
if (!provider || !nextModel) return ''
return (
carriedReasoning(
typeof effort === 'string' ? effort : undefined,
explicitOffToken(provider, nextModel) ?? '',
getReasoningCapability(provider, nextModel)
) ?? ''
)
}
function selectResource(path: string, picked: AIProvider) {
setFields({
kind: picked,
resource: `$res:${path}`,
// The models of one provider mean nothing to another, and the new list only
// arrives async, so there is nothing to carry the current one against.
model: undefined
// arrives async, so there is nothing to carry the current one against — nor the
// effort, which only means something against a model.
model: undefined,
reasoning_effort: ''
})
}
@@ -197,7 +222,7 @@
key: m,
label: m,
selected: m === model,
onSelect: () => setFields({ model: m })
onSelect: () => setFields({ model: m, reasoning_effort: effortFor(m) })
})),
loading: models.loading,
emptyMessage: provider ? 'No model available' : 'Pick a provider first'
@@ -146,19 +146,7 @@ describe('agentModelWiringInputs', () => {
const wiring = resolveAgentModelWiring([
agent(`({ kind: flow_input.k, resource: flow_input.r, model: flow_input.m })`)
])
expect(agentModelWiringInputs(wiring, true)?.sort()).toEqual(['k', 'm', 'r'])
})
// The slider only appears for a model that reasons, so on one that does not the effort
// input has no editor on the button and has to stay in the modal.
it('keeps a reasoning_effort input where the model cannot think', () => {
const wiring = resolveAgentModelWiring([
agent(
`({ "kind": "openai", "resource": "$res:u/admin/oai", "model": "gpt-4o", reasoning_effort: flow_input.thinking })`
)
])
expect(agentModelWiringInputs(wiring, false)).toEqual([])
expect(agentModelWiringInputs(wiring, true)).toEqual(['thinking'])
expect(agentModelWiringInputs(wiring)?.sort()).toEqual(['k', 'm', 'r'])
})
})
@@ -289,23 +289,23 @@ export function agentModelGap(wiring: AgentModelWiring | undefined): string | un
* The flow inputs the model button actually writes, so the modal does not ask for them a
* second time — and, just as much, so it still asks for the ones the button cannot reach.
*
* Two fields are conditional. The button writes `kind` only alongside a resource, since a
* provider is picked as a pair: a flow that wires `kind` while fixing the resource leaves
* the button nothing to write it with. And it offers the thinking slider only where the
* model reasons, so on a model that does not, a wired `reasoning_effort` has no editor
* there either. Hiding either one would leave the run short of an input with nowhere to
* supply it — and a required one would pass the modal's own completeness check.
* `kind` is the one to watch: the button writes it only alongside a resource, since a
* provider is picked as a pair. A flow that wires `kind` to an input while fixing the
* resource leaves the button nothing to write it with, and hiding it would leave the run
* without a provider kind and no way to supply one.
*
* `reasoning_effort` is unconditional by contrast. Once a model is chosen the button has a
* control for it — the slider, or the row saying the model cannot think — and on a model
* that cannot, the value is not a choice but a fact the button writes itself. Offering it in
* the modal as well would invite setting a level the provider then rejects. Before a model
* is chosen it is offered nowhere, which is the honest answer: an effort means nothing
* until there is something to spend it on, and picking a model writes one.
*/
export function agentModelWiringInputs(
wiring: AgentModelWiring | undefined,
/** Whether the model in use reasons at all. False whenever it cannot be determined. */
effortEditable: boolean = false
): string[] {
export function agentModelWiringInputs(wiring: AgentModelWiring | undefined): string[] {
if (!wiring) return []
if (wiring.whole) return [wiring.whole]
const driven: ProviderField[] = ['resource', 'model']
const driven: ProviderField[] = ['resource', 'model', 'reasoning_effort']
if (wiring.fields.resource !== undefined) driven.push('kind')
if (effortEditable) driven.push('reasoning_effort')
return driven.map((field) => wiring.fields[field]).filter((name): name is string => !!name)
}
+44 -2
View File
@@ -102,9 +102,51 @@ tool, `websearch` for web search.
}
```
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. 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
+44 -2
View File
@@ -133,9 +133,51 @@ tool, \`websearch\` for web search.
}
\`\`\`
- \`provider\` is a static object, not a bare resource string: \`{ "kind": <provider kind>,
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }\`. 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
@@ -190,9 +190,51 @@ tool, `websearch` for web search.
}
```
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. 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
+44 -2
View File
@@ -102,9 +102,51 @@ tool, `websearch` for web search.
}
```
- `provider` is a static object, not a bare resource string: `{ "kind": <provider kind>,
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. 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