mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
2c79a2f143
commit
b40219652f
@@ -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(),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
Generated
+44
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user