mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
feat: let a linked agent step override the model and reasoning effort
This commit is contained in:
@@ -260,6 +260,31 @@ fn keep_authored_memory_id(
|
||||
}
|
||||
}
|
||||
|
||||
/// A step's own `model` and `reasoning_effort` inputs replace the provider's, which is how a step
|
||||
/// linked to a saved agent picks them per run. An empty value keeps the provider's. A reasoning
|
||||
/// token is per model, so a different model without an effort of its own runs at its default.
|
||||
fn override_provider_model(
|
||||
provider: &mut ProviderWithResource,
|
||||
local_args: &HashMap<String, Box<RawValue>>,
|
||||
) {
|
||||
let given = |key: &str| {
|
||||
local_args
|
||||
.get(key)
|
||||
.and_then(|v| serde_json::from_str::<String>(v.get()).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
};
|
||||
if let Some(model) = given("model") {
|
||||
if model != provider.model {
|
||||
provider.model = model;
|
||||
provider.reasoning_effort = None;
|
||||
}
|
||||
}
|
||||
if let Some(effort) = given("reasoning_effort") {
|
||||
provider.reasoning_effort = Some(effort);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -854,6 +879,7 @@ pub async fn handle_ai_agent_job(
|
||||
};
|
||||
|
||||
keep_authored_memory_id(&mut args, &step_input_transforms);
|
||||
override_provider_model(&mut args.provider, &local_args);
|
||||
|
||||
// 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 or AIDecision tools (the editor can't
|
||||
@@ -2940,6 +2966,41 @@ mod tests {
|
||||
assert!(bounded_prompt_cache_key(&long).len() <= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_model_and_effort_replace_the_providers() {
|
||||
use serde_json::json;
|
||||
let run = |step: serde_json::Value| {
|
||||
let mut provider: ProviderWithResource = serde_json::from_value(json!({
|
||||
"kind": "openai",
|
||||
"resource": {},
|
||||
"model": "saved-model",
|
||||
"reasoning_effort": "high"
|
||||
}))
|
||||
.unwrap();
|
||||
let local_args = serde_json::from_value(step).unwrap();
|
||||
override_provider_model(&mut provider, &local_args);
|
||||
(provider.model, provider.reasoning_effort)
|
||||
};
|
||||
let saved = ("saved-model".to_string(), Some("high".to_string()));
|
||||
|
||||
assert_eq!(run(json!({})), saved);
|
||||
assert_eq!(run(json!({ "model": "", "reasoning_effort": null })), saved);
|
||||
assert_eq!(run(json!({ "model": "saved-model" })), saved);
|
||||
assert_eq!(
|
||||
run(json!({ "reasoning_effort": "low" })),
|
||||
("saved-model".to_string(), Some("low".to_string()))
|
||||
);
|
||||
// The saved effort names a level of the saved model, so it does not follow to another one.
|
||||
assert_eq!(
|
||||
run(json!({ "model": "other" })),
|
||||
("other".to_string(), None)
|
||||
);
|
||||
assert_eq!(
|
||||
run(json!({ "model": "other", "reasoning_effort": "low" })),
|
||||
("other".to_string(), Some("low".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_tool_inputs_binds_matching_flowmodule_tool_only() {
|
||||
fn js(expr: &str) -> InputTransform {
|
||||
|
||||
Generated
+5
-1
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@
|
||||
normalizeAgentRef,
|
||||
type LinkedAgentDraft
|
||||
} from './flows/linkedAgentDrafts'
|
||||
import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils'
|
||||
import { AGENT_LINKED_STEP_KEYS } from './flows/agentResourceUtils'
|
||||
import { agentTestInputTransforms } from './flows/agentFormFields'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { quickjsReady } from '$lib/utils/quickjsEval.svelte'
|
||||
@@ -186,7 +186,7 @@
|
||||
const inputTransforms = agentTestInputTransforms(
|
||||
agentVal.agent ? {} : ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>),
|
||||
args,
|
||||
draft ? AGENT_FLOW_LOCAL_KEYS : Object.keys(args)
|
||||
draft ? AGENT_LINKED_STEP_KEYS : Object.keys(args)
|
||||
)
|
||||
|
||||
await jobLoader?.runFlowPreview(
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -46,7 +46,7 @@ import type {
|
||||
ScriptLang
|
||||
} from '$lib/gen/types.gen'
|
||||
import { loadSchemaFromModule, memoryPropertyFor } from '$lib/components/flows/flowInfers'
|
||||
import { flowLocalAgentSchema } from '$lib/components/flows/agentResourceUtils'
|
||||
import { linkedAgentStepSchema } from '$lib/components/flows/agentResourceUtils'
|
||||
import { AGENT_FIELDS, initialVisibleAgentFields } from '$lib/components/flows/agentFormFields'
|
||||
import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts'
|
||||
import { resolveLinkedAgentTools } from '$lib/components/flows/flowState'
|
||||
@@ -6092,9 +6092,9 @@ async function agentStepRunForm(
|
||||
): Promise<{ schema: Record<string, any>; args: Record<string, any> }> {
|
||||
const transforms = (module.value as { input_transforms?: Record<string, any> }).input_transforms
|
||||
// A linked step's brain belongs to the resource rather than to this flow, so only the inputs
|
||||
// this flow supplies are its to edit.
|
||||
// this flow supplies, and its overrides, are its to edit.
|
||||
const full = (await loadSchemaFromModule(module, workspace)).schema
|
||||
const schema = (module.value as { agent?: string }).agent ? flowLocalAgentSchema(full) : full
|
||||
const schema = (module.value as { agent?: string }).agent ? linkedAgentStepSchema(full) : full
|
||||
|
||||
const visible = initialVisibleAgentFields(transforms, schema.properties)
|
||||
// What the model asked to set, shown even where the step configures nothing: this form has no
|
||||
|
||||
@@ -10,15 +10,21 @@ import type { InputTransform, MemoryConfig } from '$lib/gen'
|
||||
* the two cannot cycle.
|
||||
*/
|
||||
|
||||
export type AgentFieldGroup = 'model' | 'messages' | 'tools' | 'output'
|
||||
export type AgentFieldGroup = 'model' | 'messages' | 'tools' | 'output' | 'overrides'
|
||||
|
||||
/** Groups in the order a run assembles the request: pick a model, build the messages, let it call
|
||||
* tools, shape what comes back. */
|
||||
export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string }[] = [
|
||||
* tools, shape what comes back. Overrides come last: only a step linked to a saved agent has
|
||||
* them, and they replace what the agent saved rather than add to the request. */
|
||||
export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string; caption?: string }[] = [
|
||||
{ id: 'model', label: 'Model' },
|
||||
{ id: 'messages', label: 'Messages' },
|
||||
{ id: 'tools', label: 'Tools' },
|
||||
{ id: 'output', label: 'Output' }
|
||||
{ id: 'output', label: 'Output' },
|
||||
{
|
||||
id: 'overrides',
|
||||
label: 'Agent overrides',
|
||||
caption: "Replace what is saved on the agent, for this step's runs only."
|
||||
}
|
||||
]
|
||||
|
||||
/** The tool roster, which reads `flowModule.value.tools` rather than an `input_transforms` key.
|
||||
@@ -30,6 +36,17 @@ export const AGENT_TOOLS_ROW = 'tools'
|
||||
export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const
|
||||
export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number]
|
||||
|
||||
/** A linked step's overrides of its saved agent. Never seeded either: a step with no override
|
||||
* holds no key for one. */
|
||||
export const AGENT_OVERRIDE_KEYS = ['model', 'reasoning_effort'] as const
|
||||
export type AgentOverrideKey = (typeof AGENT_OVERRIDE_KEYS)[number]
|
||||
|
||||
/** The inputs whose row exists exactly while the step holds the key. */
|
||||
export const AGENT_UNSEEDED_KEYS: readonly string[] = [
|
||||
...AGENT_HISTORY_KEYS,
|
||||
...AGENT_OVERRIDE_KEYS
|
||||
]
|
||||
|
||||
/** The input transforms a one-off test of an agent step runs with: each form key is fed from
|
||||
* `flow_input`, over the step's own transforms. 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
|
||||
@@ -256,6 +273,24 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
implicit: true,
|
||||
defaultHint: 'Default: on',
|
||||
textOnly: true
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
group: 'overrides',
|
||||
label: 'Model',
|
||||
tooltip:
|
||||
"Runs this step on another model than the one saved on the agent, through the agent's own provider resource. Set it to an expression to choose the model per run: an empty value keeps the saved model.",
|
||||
implicit: '',
|
||||
defaultHint: 'Default: the model saved on the agent'
|
||||
},
|
||||
{
|
||||
key: 'reasoning_effort',
|
||||
group: 'overrides',
|
||||
label: 'Reasoning effort',
|
||||
tooltip:
|
||||
'Runs this step at another reasoning effort than the one saved on the agent. Set it to an expression to choose the effort per run: an empty value keeps the saved effort. Overriding the model without it runs that model at its default effort.',
|
||||
implicit: '',
|
||||
defaultHint: 'Default: the effort saved on the agent'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
flowLocalInputs,
|
||||
inputTransformsToAgentConfig,
|
||||
nonStaticBrainKeys,
|
||||
providerWithOverrides,
|
||||
summarizeAgentBrain,
|
||||
toolInputOverrides,
|
||||
transformValuedBrainKeys
|
||||
@@ -193,3 +194,39 @@ describe('toolInputOverrides', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerWithOverrides', () => {
|
||||
const saved = {
|
||||
kind: 'openai',
|
||||
resource: '$res:u/a/openai',
|
||||
model: 'gpt-5',
|
||||
reasoning_effort: 'high'
|
||||
}
|
||||
|
||||
it('folds static overrides into a static provider, as the worker reads them', () => {
|
||||
expect(
|
||||
providerWithOverrides(saved, { reasoning_effort: { type: 'static', value: 'low' } })
|
||||
).toEqual({ type: 'static', value: { ...saved, reasoning_effort: 'low' } })
|
||||
// The saved effort names a level of the saved model, so it does not follow to another one.
|
||||
expect(
|
||||
providerWithOverrides(saved, { model: { type: 'static', value: 'gpt-5-mini' } })
|
||||
).toEqual({
|
||||
type: 'static',
|
||||
value: { kind: 'openai', resource: '$res:u/a/openai', model: 'gpt-5-mini' }
|
||||
})
|
||||
expect(providerWithOverrides(saved, { model: { type: 'static', value: '' } })).toEqual({
|
||||
type: 'static',
|
||||
value: saved
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an expression, falling back to the saved value when it is empty', () => {
|
||||
const folded = providerWithOverrides(saved, {
|
||||
reasoning_effort: { type: 'javascript', expr: 'flow_input.effort' }
|
||||
}) as { type: 'javascript'; expr: string }
|
||||
expect(folded.type).toBe('javascript')
|
||||
const run = (effort: unknown) => new Function('flow_input', `return ${folded.expr}`)({ effort })
|
||||
expect(run('low')).toEqual({ ...saved, reasoning_effort: 'low' })
|
||||
expect(run('')).toEqual(saved)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { InputTransform } from '$lib/gen'
|
||||
import { AGENT_FIELDS, describeMemoryPolicy } from './agentFormFields'
|
||||
import {
|
||||
AGENT_FIELDS,
|
||||
AGENT_OVERRIDE_KEYS,
|
||||
describeMemoryPolicy,
|
||||
type AgentOverrideKey
|
||||
} 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.
|
||||
@@ -31,6 +36,13 @@ export const AGENT_FLOW_LOCAL_KEYS = [
|
||||
'previous_messages'
|
||||
] as const
|
||||
|
||||
/** Every input a linked step holds itself: the flow-local ones, and its overrides of what the
|
||||
* agent saved inside `provider`. */
|
||||
export const AGENT_LINKED_STEP_KEYS: readonly string[] = [
|
||||
...AGENT_FLOW_LOCAL_KEYS,
|
||||
...AGENT_OVERRIDE_KEYS
|
||||
]
|
||||
|
||||
export type AgentTool = Record<string, any>
|
||||
|
||||
/**
|
||||
@@ -59,7 +71,7 @@ export function nonStaticBrainKeys(
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the flow-local inputs on a step's input_transforms. Used when linking: the brain comes
|
||||
* Keep only the inputs a linked step holds on a step's input_transforms. Used when linking: the brain comes
|
||||
* from the resource, so the step must not carry stale `provider`/`system_prompt`/… transforms — at
|
||||
* runtime they'd still be resolved (an unnecessary, possibly failing provider `$res:` fetch) yet
|
||||
* never used, since the linked branch takes the brain from the resource.
|
||||
@@ -68,7 +80,7 @@ export function flowLocalInputs(
|
||||
inputTransforms: Record<string, InputTransform> | undefined
|
||||
): Record<string, InputTransform> {
|
||||
const out: Record<string, InputTransform> = {}
|
||||
for (const key of AGENT_FLOW_LOCAL_KEYS) {
|
||||
for (const key of AGENT_LINKED_STEP_KEYS) {
|
||||
if (inputTransforms?.[key]) {
|
||||
out[key] = inputTransforms[key]
|
||||
}
|
||||
@@ -149,6 +161,76 @@ export function flowLocalAgentSchema(schema: any): any {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema of a step linked to a saved agent, in a flow: its flow-local inputs, plus the saved
|
||||
* settings it may override.
|
||||
*/
|
||||
export function linkedAgentStepSchema(schema: any): any {
|
||||
const local = flowLocalAgentSchema(schema)
|
||||
if (!local?.properties) {
|
||||
return local
|
||||
}
|
||||
return {
|
||||
...local,
|
||||
properties: {
|
||||
...local.properties,
|
||||
model: {
|
||||
type: 'string',
|
||||
description: "Replaces the model saved on the agent for this step's runs."
|
||||
},
|
||||
reasoning_effort: {
|
||||
type: 'string',
|
||||
description: "Replaces the reasoning effort saved on the agent for this step's runs."
|
||||
}
|
||||
},
|
||||
order: [...local.order, ...AGENT_OVERRIDE_KEYS]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `provider` of a step unlinked from its agent: the saved one with the step's overrides folded
|
||||
* in, since an unlinked step sets model and effort nowhere else. Mirrors the worker: an empty
|
||||
* override keeps the saved value, and another model drops the saved effort.
|
||||
*/
|
||||
export function providerWithOverrides(
|
||||
provider: any,
|
||||
overrides: Partial<Record<AgentOverrideKey, InputTransform>> | undefined
|
||||
): InputTransform | undefined {
|
||||
if (!provider || typeof provider !== 'object') return undefined
|
||||
const set = (key: AgentOverrideKey): any => {
|
||||
const t = overrides?.[key] as any
|
||||
if (t?.type === 'javascript' && t.expr) return t
|
||||
if (t?.type === 'static' && typeof t.value === 'string' && t.value.trim()) return t
|
||||
return undefined
|
||||
}
|
||||
const model = set('model')
|
||||
const effort = set('reasoning_effort')
|
||||
const folded = { ...provider }
|
||||
if (model && !(model.type === 'static' && model.value.trim() === provider.model) && !effort) {
|
||||
delete folded.reasoning_effort
|
||||
}
|
||||
if (model?.type !== 'javascript' && effort?.type !== 'javascript') {
|
||||
if (model) folded.model = model.value.trim()
|
||||
if (effort) folded.reasoning_effort = effort.value.trim()
|
||||
return { type: 'static', value: folded }
|
||||
}
|
||||
const field = (key: AgentOverrideKey, t: any) => {
|
||||
if (!t) return folded[key] === undefined ? [] : [`${key}: ${JSON.stringify(folded[key])}`]
|
||||
if (t.type === 'static') return [`${key}: ${JSON.stringify(t.value.trim())}`]
|
||||
const saved = folded[key] === undefined ? 'undefined' : JSON.stringify(folded[key])
|
||||
return [`${key}: (${t.expr}) || ${saved}`]
|
||||
}
|
||||
const { model: _m, reasoning_effort: _e, ...rest } = folded
|
||||
const fields = [
|
||||
...Object.entries(rest).map(
|
||||
([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`
|
||||
),
|
||||
...field('model', model),
|
||||
...field('reasoning_effort', effort)
|
||||
]
|
||||
return { type: 'javascript', expr: `({ ${fields.join(', ')} })` }
|
||||
}
|
||||
|
||||
/** Read off the form's own registry, so a linked agent's summary cannot name a field differently
|
||||
* from the form that edits it. */
|
||||
export const AGENT_BRAIN_LABELS: Record<string, string> = Object.fromEntries(
|
||||
@@ -182,7 +264,7 @@ export function summarizeAgentBrain(
|
||||
if (v === undefined || v === null || v === '') continue
|
||||
let value: string
|
||||
if (key === 'provider') {
|
||||
value = [v.kind, v.model].filter(Boolean).join(' · ') || 'configured'
|
||||
value = [v.kind, v.model, v.reasoning_effort].filter(Boolean).join(' · ') || 'configured'
|
||||
} else if (key === 'memory') {
|
||||
value = typeof v === 'object' ? describeMemoryPolicy(v) : String(v)
|
||||
} else if (key === 'output_schema') {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
flowLocalInputs,
|
||||
inputTransformsToAgentConfig,
|
||||
nonStaticBrainKeys,
|
||||
providerWithOverrides,
|
||||
summarizeAgentBrain,
|
||||
type AIAgentConfig,
|
||||
type AgentTool
|
||||
@@ -61,7 +62,7 @@
|
||||
flowPath = '',
|
||||
fromAgentEditor = false,
|
||||
chatInputEnabled = false,
|
||||
linkedMemory = $bindable()
|
||||
linkedAgent = $bindable()
|
||||
}: {
|
||||
agent: string | undefined
|
||||
inputTransforms: Record<string, InputTransform>
|
||||
@@ -80,8 +81,9 @@
|
||||
// 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
|
||||
// The linked agent's memory and provider once its config has loaded, for the step's history
|
||||
// inputs and its overrides.
|
||||
linkedAgent?: { memory: unknown; provider?: unknown } | undefined
|
||||
} = $props()
|
||||
|
||||
let ws = $derived(opWorkspace ?? $operatingWorkspace)
|
||||
@@ -226,7 +228,9 @@
|
||||
loadedInfo?.ws === ws && loadedInfo?.path === agent ? loadedInfo : undefined
|
||||
)
|
||||
$effect(() => {
|
||||
linkedMemory = linkedInfo ? { memory: linkedInfo.config?.memory } : undefined
|
||||
linkedAgent = linkedInfo
|
||||
? { memory: linkedInfo.config?.memory, provider: linkedInfo.config?.provider }
|
||||
: undefined
|
||||
})
|
||||
let inheritedTools = $derived(linkedInfo?.tools ?? [])
|
||||
let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config))
|
||||
@@ -512,7 +516,13 @@
|
||||
local[key] = inputTransforms[key]
|
||||
}
|
||||
}
|
||||
const forkedInputs = { ...agentConfigToInputTransforms(cfg), ...local }
|
||||
// An unlinked step sets model and effort inside `provider`, so its overrides move there.
|
||||
const provider = providerWithOverrides(cfg.provider, inputTransforms)
|
||||
const forkedInputs = {
|
||||
...agentConfigToInputTransforms(cfg),
|
||||
...(provider ? { provider } : {}),
|
||||
...local
|
||||
}
|
||||
const forkedTools = cfg.tools ?? []
|
||||
inputTransforms = forkedInputs
|
||||
for (const tool of forkedTools) {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<script lang="ts">
|
||||
import type { Schema } from '$lib/common'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { type InputTransform } from '$lib/gen'
|
||||
import { type AIProvider, type InputTransform } from '$lib/gen'
|
||||
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
|
||||
import { getContext, untrack, type Snippet } from 'svelte'
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
@@ -46,6 +46,12 @@
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import { AlertTriangle, Plus, X } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
import { aiProviderDetails, fetchAvailableModels } from '$lib/components/copilot/lib'
|
||||
import {
|
||||
explicitOffToken,
|
||||
getReasoningCapability
|
||||
} from '$lib/components/copilot/reasoningRegistry'
|
||||
import type { PickableProperties } from '../previousResults'
|
||||
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
|
||||
import { enabledToolNames, type AgentTool } from '../agentToolUtils'
|
||||
@@ -53,6 +59,7 @@
|
||||
AGENT_FIELDS,
|
||||
AGENT_FIELD_BY_KEY,
|
||||
AGENT_HISTORY_KEYS,
|
||||
AGENT_UNSEEDED_KEYS,
|
||||
AGENT_MEMORY_DOCS_URL,
|
||||
AGENT_TOOLS_ROW,
|
||||
AGENT_FIELD_GROUPS,
|
||||
@@ -114,9 +121,10 @@
|
||||
onDeleteTool?: (toolId: string) => void
|
||||
/** Where the tool picker's popover belongs, for a surface that is not the flow editor. */
|
||||
toolPickerPortal?: string
|
||||
/** A linked agent's memory, once its config has loaded: whether it keeps managed memory decides
|
||||
* which history inputs the step offers. */
|
||||
linkedMemory?: { memory: unknown } | undefined
|
||||
/** A linked agent's saved config, once it has loaded: whether it keeps managed memory decides
|
||||
* which history inputs the step offers, and its provider is what the step's overrides
|
||||
* replace. */
|
||||
linkedAgent?: { memory: unknown; provider?: unknown } | undefined
|
||||
/** Set when this agent's config lives in a resource, so `tools` arrives with it rather than
|
||||
* with the step. */
|
||||
agentLinked?: boolean
|
||||
@@ -150,7 +158,7 @@
|
||||
onAddTool = undefined,
|
||||
onDeleteTool = undefined,
|
||||
toolPickerPortal = undefined,
|
||||
linkedMemory = undefined,
|
||||
linkedAgent = undefined,
|
||||
agentLinked = false
|
||||
}: Props = $props()
|
||||
|
||||
@@ -195,7 +203,7 @@
|
||||
? agentMemoryMode(transform?.value)
|
||||
: undefined
|
||||
}
|
||||
return linkedMemory ? agentMemoryMode(linkedMemory.memory) : undefined
|
||||
return linkedAgent ? agentMemoryMode(linkedAgent.memory) : undefined
|
||||
})
|
||||
|
||||
// The one-of field rewrites a value that matches none of its options, so a legacy kind the step
|
||||
@@ -212,6 +220,83 @@
|
||||
return (AGENT_HISTORY_KEYS as readonly string[]).includes(key)
|
||||
}
|
||||
|
||||
let savedProvider = $derived(
|
||||
linkedAgent?.provider as
|
||||
| { kind?: AIProvider; resource?: string; model?: string; reasoning_effort?: string }
|
||||
| undefined
|
||||
)
|
||||
|
||||
// The models the agent's own provider resource serves, since an override keeps that resource.
|
||||
const savedProviderModels = resource(
|
||||
() => ({
|
||||
ws,
|
||||
kind: savedProvider?.kind,
|
||||
path: savedProvider?.resource?.replace(/^\$res:/, ''),
|
||||
shown: args?.model != undefined
|
||||
}),
|
||||
async ({ ws, kind, path, shown }, _prev, { onCleanup }) => {
|
||||
if (!shown || !ws || !kind || !path) return []
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
try {
|
||||
return await fetchAvailableModels(path, ws, kind, controller.signal)
|
||||
} catch {
|
||||
return aiProviderDetails(kind).defaultModels
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// The model the effort is placed on: the step's own where it names one, else the agent's. An
|
||||
// expression is only known once the run is under way, so it leaves the effort free text.
|
||||
let effortModel = $derived.by(() => {
|
||||
const transform = args?.model
|
||||
if (transform?.type === 'javascript') return undefined
|
||||
const own = transform?.type === 'static' ? transform.value : undefined
|
||||
return (typeof own === 'string' && own.trim()) || savedProvider?.model
|
||||
})
|
||||
let effortCapability = $derived(
|
||||
savedProvider?.kind && effortModel
|
||||
? getReasoningCapability(savedProvider.kind, effortModel)
|
||||
: undefined
|
||||
)
|
||||
let effortChoices = $derived.by(() => {
|
||||
if (!savedProvider?.kind || !effortModel || !effortCapability?.supported) return undefined
|
||||
const off = explicitOffToken(savedProvider.kind, effortModel)
|
||||
return [...(effortCapability.canDisable && off ? [off] : []), ...effortCapability.levels]
|
||||
})
|
||||
|
||||
// The override rows offer what the agent's provider has, where that is known. Custom values stay
|
||||
// typeable: a resource may serve a model its listing omits. Held in state of their own, since the
|
||||
// field binds into the property it renders.
|
||||
let overrideProperties: Record<string, any> = $state({ model: {}, reasoning_effort: {} })
|
||||
$effect(() => {
|
||||
const model = {
|
||||
...schemaProperties.model,
|
||||
...(savedProviderModels.current?.length ? { enum: savedProviderModels.current } : {})
|
||||
}
|
||||
const effort = {
|
||||
...schemaProperties.reasoning_effort,
|
||||
...(effortChoices ? { enum: effortChoices } : {})
|
||||
}
|
||||
untrack(() => {
|
||||
if (!deepEqual($state.snapshot(overrideProperties.model), model)) {
|
||||
overrideProperties.model = model
|
||||
}
|
||||
if (!deepEqual($state.snapshot(overrideProperties.reasoning_effort), effort)) {
|
||||
overrideProperties.reasoning_effort = effort
|
||||
}
|
||||
})
|
||||
})
|
||||
let overrideFieldSchema = $derived({
|
||||
...schema,
|
||||
properties: { ...schemaProperties, ...overrideProperties }
|
||||
})
|
||||
|
||||
function fieldSchema(key: string) {
|
||||
if (key === 'memory') return memoryFieldSchema
|
||||
return key === 'model' || key === 'reasoning_effort' ? overrideFieldSchema : schema
|
||||
}
|
||||
|
||||
// Offer the agent's own tools as the choices for `enabled_tools`, rather than asking for names
|
||||
// to be typed. Written into the schema because that is where `InputTransformForm` reads a
|
||||
// field's shape from; `flowInfers` hands every step its own copy, so this stays this step's.
|
||||
@@ -260,7 +345,7 @@
|
||||
? args?.memory?.type === 'static'
|
||||
? args.memory.value
|
||||
: undefined
|
||||
: linkedMemory?.memory
|
||||
: linkedAgent?.memory
|
||||
)
|
||||
return onThisForm
|
||||
? `Ignored while memory is set to ${label}.`
|
||||
@@ -272,10 +357,11 @@
|
||||
scopedFields.some((spec) => spec.key === 'memory_id')
|
||||
)
|
||||
|
||||
// A history input's row follows its key: the remembered `visible` set can outlive a key that a
|
||||
// save, an undo or the AI chat removed, and a row with no value renders no field.
|
||||
// A history input's or an override's row follows its key: the remembered `visible` set can
|
||||
// outlive a key that a save, an undo or the AI chat removed, and a row with no value renders no
|
||||
// field.
|
||||
function isShown(key: string): boolean {
|
||||
if (isHistoryKey(key)) {
|
||||
if (AGENT_UNSEEDED_KEYS.includes(key)) {
|
||||
return args?.[key] != undefined || (key === 'memory_id' && memoryIdOffered)
|
||||
}
|
||||
return visible.has(key)
|
||||
@@ -330,12 +416,13 @@
|
||||
})
|
||||
})
|
||||
|
||||
// A history input's row follows its key rather than `visible`, so the run form is told about one
|
||||
// only while the step holds the key: an inherited memory id has nothing a test run could inherit.
|
||||
// A history input's or an override's row follows its key rather than `visible`, so the run form
|
||||
// is told about one only while the step holds the key: an inherited memory id has nothing a test
|
||||
// run could inherit.
|
||||
$effect(() => {
|
||||
const keys = [
|
||||
...[...visible].filter((key) => !isHistoryKey(key)),
|
||||
...AGENT_HISTORY_KEYS.filter((key) => args?.[key] != undefined)
|
||||
...[...visible].filter((key) => !AGENT_UNSEEDED_KEYS.includes(key)),
|
||||
...AGENT_UNSEEDED_KEYS.filter((key) => args?.[key] != undefined)
|
||||
]
|
||||
untrack(() => rememberOpenFields(visibilityKey, keys))
|
||||
})
|
||||
@@ -372,7 +459,7 @@
|
||||
function removeField(spec: AgentFieldSpec) {
|
||||
visible.delete(spec.key)
|
||||
if (args) {
|
||||
if (isHistoryKey(spec.key)) {
|
||||
if (AGENT_UNSEEDED_KEYS.includes(spec.key)) {
|
||||
delete args[spec.key]
|
||||
} else {
|
||||
// Back to exactly what `flowInfers` seeds, so removing a field leaves no diff behind.
|
||||
@@ -455,9 +542,9 @@
|
||||
{previousModuleId}
|
||||
bind:arg={args[key]}
|
||||
bind:schema={
|
||||
() => (key === 'memory' ? memoryFieldSchema : schema),
|
||||
() => fieldSchema(key),
|
||||
(value) => {
|
||||
if (key !== 'memory') schema = value
|
||||
if (fieldSchema(key) === schema) schema = value
|
||||
}
|
||||
}
|
||||
argName={key}
|
||||
@@ -469,7 +556,7 @@
|
||||
indentUnderHeader={false}
|
||||
{collapsed}
|
||||
animateAppear={header != undefined}
|
||||
argExtra={schemaProperties[key] ?? {}}
|
||||
argExtra={fieldSchema(key)?.properties?.[key] ?? {}}
|
||||
bind:inputCheck={() => inputCheck[key] ?? false, (value) => (inputCheck[key] = value)}
|
||||
bind:extraLib={() => extraLib ?? 'missing extraLib', (v) => (extraLib = v)}
|
||||
{variableEditor}
|
||||
@@ -558,6 +645,9 @@
|
||||
{#if rows.length > 0}
|
||||
<div class="w-full flex flex-col">
|
||||
<h2 class="mb-1 text-2xs font-normal uppercase text-secondary">{group.label}</h2>
|
||||
{#if group.caption}
|
||||
<p class="mb-2 text-xs text-secondary">{group.caption}</p>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-6">
|
||||
{#each rows as spec (spec.key)}
|
||||
<ResizeTransitionWrapper innerClass="w-full" vertical>
|
||||
@@ -612,6 +702,15 @@
|
||||
: `Ignored while managed memory is ${memoryMode === 'managed' ? 'on' : 'off'}.`}
|
||||
</p>
|
||||
{/if}
|
||||
{#if spec.group === 'overrides' && savedProvider}
|
||||
<p class="mt-1 text-2xs text-hint">
|
||||
{#if spec.key === 'reasoning_effort' && effortCapability?.known && !effortCapability.supported}
|
||||
{effortModel} does not take a reasoning effort.
|
||||
{:else}
|
||||
Saved on the agent: {savedProvider[spec.key] || 'none'}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if spec.key === 'enabled_tools' && noToolsEnabled}
|
||||
<div
|
||||
class="mt-1 flex items-center gap-1 text-2xs text-yellow-600 dark:text-yellow-400"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
import AiAgentStepInputs from './AiAgentStepInputs.svelte'
|
||||
import AgentToolBindings from './AgentToolBindings.svelte'
|
||||
import { getLinkedAgentTools, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
|
||||
import { flowLocalAgentSchema } from '../agentResourceUtils'
|
||||
import { linkedAgentStepSchema } from '../agentResourceUtils'
|
||||
import { AI_AGENT_TOOL_AI_KEYS, AI_DECISION_TOOL_AI_KEYS } from '../agentToolUtils'
|
||||
import { setAiDecisionStep } from '../aiDecisionBranching'
|
||||
import DiffEditor from '$lib/components/DiffEditor.svelte'
|
||||
@@ -309,7 +309,7 @@
|
||||
let inputTransformSchemaForm: { setArgs: (nargs: Record<string, any>) => 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 linkedAgentConfig: { memory: unknown; provider?: unknown } | undefined = $state(undefined)
|
||||
|
||||
let reloadError: string | undefined = $state(undefined)
|
||||
async function reload(flowModule: FlowModule) {
|
||||
@@ -1180,7 +1180,7 @@
|
||||
opWorkspace={opWs}
|
||||
flowPath={$pathStore}
|
||||
fromAgentEditor={agentEditorHost?.() != undefined}
|
||||
bind:linkedMemory={linkedAgentMemory}
|
||||
bind:linkedAgent={linkedAgentConfig}
|
||||
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
|
||||
bind:agent={
|
||||
() =>
|
||||
@@ -1233,7 +1233,7 @@
|
||||
bind:this={inputTransformSchemaForm}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
schema={agentLinked
|
||||
? flowLocalAgentSchema(
|
||||
? linkedAgentStepSchema(
|
||||
flowStateStore.val[flowModule.id]?.schema ?? {}
|
||||
)
|
||||
: (flowStateStore.val[flowModule.id]?.schema ?? {})}
|
||||
@@ -1263,7 +1263,7 @@
|
||||
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
|
||||
workspace={opWs}
|
||||
visibilityKey={agentFieldsKey}
|
||||
linkedMemory={agentLinked ? linkedAgentMemory : undefined}
|
||||
linkedAgent={agentLinked ? linkedAgentConfig : undefined}
|
||||
{agentLinked}
|
||||
tools={agentLinked
|
||||
? getLinkedAgentTools(
|
||||
@@ -1367,7 +1367,7 @@
|
||||
mod={flowModule}
|
||||
{noEditor}
|
||||
schema={agentLinked
|
||||
? flowLocalAgentSchema(flowStateStore.val[flowModule.id]?.schema ?? {})
|
||||
? linkedAgentStepSchema(flowStateStore.val[flowModule.id]?.schema ?? {})
|
||||
: (flowStateStore.val[flowModule.id]?.schema ?? {})}
|
||||
bind:testJob
|
||||
bind:testIsLoading
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { Chat } from 'windmill-chat'
|
||||
import { saveFlowChatInputs } from './flowChatProps'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { ResourceService, type FlowModule } from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
|
||||
import {
|
||||
attachmentsTargetFor,
|
||||
@@ -23,10 +24,12 @@
|
||||
import FlowChatModelSettings from './FlowChatModelSettings.svelte'
|
||||
import {
|
||||
agentModelGap,
|
||||
chatLinkedAgentPaths,
|
||||
composerOwnedInputs,
|
||||
resolveAgentModelWiring,
|
||||
showsModelButton,
|
||||
withoutRejectedEffort
|
||||
withoutRejectedEffort,
|
||||
type SavedAgentProviders
|
||||
} from './agentChatInputs'
|
||||
|
||||
interface Props {
|
||||
@@ -107,7 +110,26 @@
|
||||
// The model gets its own button, shaped like the copilot's model settings, driven by
|
||||
// whichever provider fields the flow exposes. Attachments are the paperclip's; every other
|
||||
// flow input is asked for in the Configure-inputs modal.
|
||||
const modelWiring = $derived(resolveAgentModelWiring(flowModules))
|
||||
// A step linked to a saved agent takes its provider from the agent, so that is read here. One
|
||||
// the reader cannot load leaves the wiring unreadable, and the model button away.
|
||||
const savedProviders = resource(
|
||||
() => ({ workspace, paths: chatLinkedAgentPaths(flowModules) }),
|
||||
async ({ workspace, paths }): Promise<SavedAgentProviders> => {
|
||||
if (!workspace || paths.length === 0) return {}
|
||||
const entries = await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
try {
|
||||
const value: any = await ResourceService.getResourceValue({ workspace, path })
|
||||
return [path, value?.provider] as const
|
||||
} catch {
|
||||
return [path, undefined] as const
|
||||
}
|
||||
})
|
||||
)
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
)
|
||||
const modelWiring = $derived(resolveAgentModelWiring(flowModules, savedProviders.current))
|
||||
// An agent with nothing to call cannot answer, and the composer cannot fix it, so the
|
||||
// chat says what to go and do instead of offering controls that write nowhere.
|
||||
const modelGap = $derived(agentModelGap(modelWiring, subject))
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { resource } from 'runed'
|
||||
import {
|
||||
composerDrivenFields,
|
||||
wiredFieldValue,
|
||||
type AgentModelWiring,
|
||||
type ProviderField
|
||||
} from './agentChatInputs'
|
||||
@@ -40,9 +41,7 @@
|
||||
let { wiring, values, setValue, workspace }: Props = $props()
|
||||
|
||||
function fieldValue(field: ProviderField): any {
|
||||
if (wiring.whole) return values[wiring.whole]?.[field]
|
||||
const name = wiring.fields[field]
|
||||
return name ? values[name] : wiring.fixed[field]
|
||||
return wiredFieldValue(wiring, values, field)
|
||||
}
|
||||
|
||||
const driven = $derived(composerDrivenFields(wiring))
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseProviderTransform,
|
||||
resolveAgentModelWiring,
|
||||
showsModelButton,
|
||||
wiredFieldValue,
|
||||
withoutRejectedEffort
|
||||
} from './agentChatInputs'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
@@ -499,3 +500,50 @@ describe('parseProviderTransform with comments', () => {
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a step linked to a saved agent', () => {
|
||||
const saved = {
|
||||
'u/a/agent': {
|
||||
kind: 'anthropic',
|
||||
resource: '$res:u/a/claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
reasoning_effort: 'high'
|
||||
}
|
||||
}
|
||||
function linked(input_transforms: Record<string, unknown>): FlowModule {
|
||||
return {
|
||||
id: 'a',
|
||||
value: { type: 'aiagent', agent: '$res:u/a/agent', tools: [], input_transforms }
|
||||
} as unknown as FlowModule
|
||||
}
|
||||
const wired = linked({
|
||||
model: { type: 'javascript', expr: 'flow_input.model' },
|
||||
reasoning_effort: { type: 'javascript', expr: 'flow_input.effort' }
|
||||
})
|
||||
|
||||
it('drives the overrides it wires, on the provider the agent saved', () => {
|
||||
const wiring = resolveAgentModelWiring([wired], saved)
|
||||
expect(wiring?.fields).toEqual({ model: 'model', reasoning_effort: 'effort' })
|
||||
expect(wiring?.fixed).toEqual({ kind: 'anthropic', resource: '$res:u/a/claude' })
|
||||
expect(agentModelWiringInputs(wiring).sort()).toEqual(['effort', 'model'])
|
||||
})
|
||||
|
||||
it('shows what the agent saved for an input left empty, the effort only on its own model', () => {
|
||||
const wiring = resolveAgentModelWiring([wired], saved)!
|
||||
expect(wiredFieldValue(wiring, {}, 'model')).toBe('claude-sonnet-4-5')
|
||||
expect(wiredFieldValue(wiring, {}, 'reasoning_effort')).toBe('high')
|
||||
expect(
|
||||
wiredFieldValue(wiring, { model: 'claude-haiku-4-5' }, 'reasoning_effort')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cannot be read until the agent has loaded, or with an expression it cannot place', () => {
|
||||
expect(resolveAgentModelWiring([wired], {})).toBeUndefined()
|
||||
expect(
|
||||
resolveAgentModelWiring(
|
||||
[linked({ model: { type: 'javascript', expr: 'pick(flow_input.model)' } })],
|
||||
saved
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,12 @@ import { carriedReasoning } from '$lib/components/copilot/chatModelSettings'
|
||||
import { parseExpressionAt } from 'acorn'
|
||||
import { agentSteps, flowInputReads } from './agentAttachmentInput'
|
||||
|
||||
/** A step names its agent bare or as `$res:<path>`/`res://<path>`, as `linkedAgentDrafts` reads it;
|
||||
* kept apart from that module, which loads the editor's draft stores. */
|
||||
function normalizeAgentRef(agentRef: string): string {
|
||||
return agentRef.replace(/^\$res:/, '').replace(/^res:\/\//, '')
|
||||
}
|
||||
|
||||
/** Block and line comments removed, so what is left is only what affects the value. */
|
||||
function withoutComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
||||
@@ -78,6 +84,79 @@ export type AgentModelWiring = {
|
||||
* which describe what the composer may offer.
|
||||
*/
|
||||
someAgentCannotRun?: boolean
|
||||
/**
|
||||
* What a step linked to a saved agent runs on where it overrides nothing: an empty model
|
||||
* input keeps the saved model, and the saved effort applies only on that model.
|
||||
*/
|
||||
saved?: { model?: string; reasoning_effort?: string }
|
||||
}
|
||||
|
||||
/** The provider saved on each linked agent, by resource path. */
|
||||
export type SavedAgentProviders = Record<string, Record<string, any> | undefined>
|
||||
|
||||
/** The saved agents whose provider the wiring needs, as bare resource paths. */
|
||||
export function chatLinkedAgentPaths(modules: FlowModule[] | undefined): string[] {
|
||||
const paths = chatFacingAgents(modules)
|
||||
.map((module) => (module.value as any).agent as string | undefined)
|
||||
.filter((agent): agent is string => !!agent)
|
||||
.map(normalizeAgentRef)
|
||||
return [...new Set(paths)].sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a step linked to a saved agent: the provider is the agent's, except for the model and
|
||||
* effort the step overrides, each a literal or one flow input.
|
||||
*/
|
||||
function parseLinkedStep(
|
||||
provider: Record<string, any> | undefined,
|
||||
transforms: Record<string, InputTransform> | undefined
|
||||
): AgentModelWiring | undefined {
|
||||
if (!provider || typeof provider !== 'object') return undefined
|
||||
const fixed: AgentModelWiring['fixed'] = {}
|
||||
for (const field of PROVIDER_FIELDS) {
|
||||
if (provider[field] !== undefined) fixed[field] = provider[field]
|
||||
}
|
||||
const saved = { model: fixed.model, reasoning_effort: fixed.reasoning_effort }
|
||||
// Read off `saved` by whoever shows it, since it holds only on the saved model.
|
||||
delete fixed.reasoning_effort
|
||||
const fields: AgentModelWiring['fields'] = {}
|
||||
for (const field of ['model', 'reasoning_effort'] as const) {
|
||||
const transform = transforms?.[field] as any
|
||||
if (!transform) continue
|
||||
if (transform.type === 'static') {
|
||||
const value = typeof transform.value === 'string' ? transform.value.trim() : ''
|
||||
if (value) fixed[field] = value
|
||||
continue
|
||||
}
|
||||
if (transform.type !== 'javascript') return undefined
|
||||
const name = flowInputName(parseExpression(transform.expr))
|
||||
if (!name) return undefined
|
||||
fields[field] = name
|
||||
delete fixed[field]
|
||||
}
|
||||
return { fields, fixed, saved }
|
||||
}
|
||||
|
||||
/**
|
||||
* The value a provider field runs on, given what the composer holds. Where the wiring is a
|
||||
* linked agent's, an empty value is the agent's saved one.
|
||||
*/
|
||||
export function wiredFieldValue(
|
||||
wiring: AgentModelWiring,
|
||||
values: Record<string, any>,
|
||||
field: ProviderField
|
||||
): any {
|
||||
if (wiring.whole) return values[wiring.whole]?.[field]
|
||||
const name = wiring.fields[field]
|
||||
const own = name ? values[name] : wiring.fixed[field]
|
||||
if (!wiring.saved || (own !== undefined && own !== '')) return own
|
||||
if (field === 'model') return wiring.saved.model
|
||||
if (field === 'reasoning_effort') {
|
||||
return wiredFieldValue(wiring, values, 'model') === wiring.saved.model
|
||||
? wiring.saved.reasoning_effort
|
||||
: own
|
||||
}
|
||||
return own
|
||||
}
|
||||
|
||||
/** The flow input behind `flow_input.x`, `flow_input?.x` or `flow_input['x']`. */
|
||||
@@ -121,23 +200,8 @@ export function parseProviderTransform(
|
||||
}
|
||||
if (transform?.type !== 'javascript') return undefined
|
||||
|
||||
// The author's own text may already be wrapped, so any balanced surround is fine — what
|
||||
// the span check rejects is an expression with something else beside it.
|
||||
const source = parenthesised(transform.expr)
|
||||
let node: any
|
||||
try {
|
||||
node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
// A comment beside the expression is not another expression: the author annotating their
|
||||
// own provider must not cost them the model control. Dropped before the check so what
|
||||
// remains is only what would change the value — and so a paren inside a comment is not
|
||||
// counted as one of the wrapping pair.
|
||||
const before = withoutComments(source.slice(0, node.start))
|
||||
const after = withoutComments(source.slice(node.end))
|
||||
if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined
|
||||
if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined
|
||||
const node = parseExpression(transform.expr)
|
||||
if (!node) return undefined
|
||||
|
||||
const whole = flowInputName(node)
|
||||
if (whole) return { whole, fields: {}, fixed: {} }
|
||||
@@ -162,6 +226,28 @@ export function parseProviderTransform(
|
||||
return { fields, fixed }
|
||||
}
|
||||
|
||||
/** The one expression a transform holds, or undefined where it is anything more than that. */
|
||||
function parseExpression(expr: string): any {
|
||||
// The author's own text may already be wrapped, so any balanced surround is fine — what
|
||||
// the span check rejects is an expression with something else beside it.
|
||||
const source = parenthesised(expr)
|
||||
let node: any
|
||||
try {
|
||||
node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
// A comment beside the expression is not another expression: the author annotating their
|
||||
// own provider must not cost them the model control. Dropped before the check so what
|
||||
// remains is only what would change the value — and so a paren inside a comment is not
|
||||
// counted as one of the wrapping pair.
|
||||
const before = withoutComments(source.slice(0, node.start))
|
||||
const after = withoutComments(source.slice(node.end))
|
||||
if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined
|
||||
if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined
|
||||
return node
|
||||
}
|
||||
|
||||
/** How one agent supplies a provider field: from an input, as a literal, or not at all. */
|
||||
type FieldSupply =
|
||||
| { kind: 'wired'; name: string }
|
||||
@@ -192,12 +278,16 @@ function fieldSupply(wiring: AgentModelWiring, field: ProviderField): FieldSuppl
|
||||
* and field-by-field wiring is ambiguous throughout and yields nothing.
|
||||
*/
|
||||
export function resolveAgentModelWiring(
|
||||
modules: FlowModule[] | undefined
|
||||
modules: FlowModule[] | undefined,
|
||||
savedProviders: SavedAgentProviders = {}
|
||||
): AgentModelWiring | undefined {
|
||||
const agents = chatFacingAgents(modules)
|
||||
const parsed = agents.map((agent) =>
|
||||
parseProviderTransform((agent.value as any).input_transforms?.['provider'])
|
||||
)
|
||||
const parsed = agents.map((agent) => {
|
||||
const value = agent.value as any
|
||||
return value.agent
|
||||
? parseLinkedStep(savedProviders[normalizeAgentRef(value.agent)], value.input_transforms)
|
||||
: parseProviderTransform(value.input_transforms?.['provider'])
|
||||
})
|
||||
if (parsed.length === 0) return undefined
|
||||
// An agent whose provider cannot be read is an agent the composer cannot speak for:
|
||||
// dropping it would let the rest declare a control that governs only some of them.
|
||||
@@ -231,7 +321,14 @@ export function resolveAgentModelWiring(
|
||||
if (supply.kind === 'wired') fields[field] = supply.name
|
||||
else if (supply.kind === 'fixed') fixed[field] = supply.value
|
||||
}
|
||||
return { fields, fixed, someAgentCannotRun }
|
||||
// An empty input means one thing only where every agent falls back to the same saved pair.
|
||||
const saved = new Set(wirings.map((w) => JSON.stringify(w.saved)))
|
||||
return {
|
||||
fields,
|
||||
fixed,
|
||||
someAgentCannotRun,
|
||||
saved: saved.size === 1 ? wirings[0].saved : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,11 +454,9 @@ export function withoutRejectedEffort(
|
||||
|
||||
const effortInput = wiring.fields.reasoning_effort
|
||||
if (!effortInput) return values
|
||||
const kindInput = wiring.fields.kind
|
||||
const modelInput = wiring.fields.model
|
||||
const rejected = rejectsEffort(
|
||||
kindInput ? values[kindInput] : wiring.fixed.kind,
|
||||
modelInput ? values[modelInput] : wiring.fixed.model,
|
||||
wiredFieldValue(wiring, values, 'kind'),
|
||||
wiredFieldValue(wiring, values, 'model'),
|
||||
values[effortInput]
|
||||
)
|
||||
return rejected ? { ...values, [effortInput]: '' } : values
|
||||
|
||||
@@ -3,8 +3,8 @@ import { loadSchemaFlow } from '$lib/scripts'
|
||||
import type { Schema } from '$lib/common'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import type { FlowModule, InputTransform } from '$lib/gen'
|
||||
import { AGENT_FLOW_LOCAL_KEYS } from './agentResourceUtils'
|
||||
import { AGENT_HISTORY_KEYS } from './agentFormFields'
|
||||
import { AGENT_LINKED_STEP_KEYS } from './agentResourceUtils'
|
||||
import { AGENT_UNSEEDED_KEYS } from './agentFormFields'
|
||||
|
||||
/** Display names for the memory field's options, so anything else naming the setting an author
|
||||
* picked cannot drift from the button they see. */
|
||||
@@ -390,20 +390,17 @@ export async function loadSchemaFromModule(
|
||||
}
|
||||
} else if (mod.type === 'aiagent') {
|
||||
let input_transforms = migrateAiAgentInputTransforms(mod.input_transforms ?? {})
|
||||
// A linked step's brain lives in the resource, so only the flow-local inputs get a placeholder
|
||||
// transform: filling the brain keys back in would re-add the very fields linking strips, and
|
||||
// they would be persisted on the next save.
|
||||
const keys = mod.agent
|
||||
? (AGENT_FLOW_LOCAL_KEYS as readonly string[])
|
||||
: Object.keys(AI_AGENT_SCHEMA.properties ?? {})
|
||||
// A linked step's brain lives in the resource, so only the inputs it holds itself are kept:
|
||||
// filling the brain keys back in would re-add the very fields linking strips, and they would
|
||||
// be persisted on the next save.
|
||||
const keys = mod.agent ? AGENT_LINKED_STEP_KEYS : Object.keys(AI_AGENT_SCHEMA.properties ?? {})
|
||||
return {
|
||||
input_transforms: keys.reduce((accu, key) => {
|
||||
const transform =
|
||||
input_transforms[key] ??
|
||||
// A present history input is the step's choice at runtime, so it gets no placeholder.
|
||||
((AGENT_HISTORY_KEYS as readonly string[]).includes(key)
|
||||
? undefined
|
||||
: { type: 'static', value: undefined })
|
||||
// A present history input or override is the step's choice at runtime, so it gets no
|
||||
// placeholder.
|
||||
(AGENT_UNSEEDED_KEYS.includes(key) ? undefined : { type: 'static', value: undefined })
|
||||
if (transform) accu[key] = transform
|
||||
return accu
|
||||
}, {}),
|
||||
|
||||
@@ -1183,6 +1183,23 @@ components:
|
||||
description: |
|
||||
Number. Limits how many times the agent can loop through reasoning and tool use.
|
||||
Range: 1-1000.
|
||||
model:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
description: |
|
||||
String. Only on a step linked to a saved agent (see `agent`): replaces the model
|
||||
saved on the agent for this step's runs, on the agent's own provider resource. An
|
||||
empty value keeps the saved model. Set it to an expression such as
|
||||
`flow_input.model` to choose the model per run.
|
||||
reasoning_effort:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
description: |
|
||||
String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning
|
||||
effort saved on the agent for this step's runs, as a provider-native token (e.g.
|
||||
'low', 'high', 'none'). An empty value keeps the saved effort, unless `model`
|
||||
replaces the saved model, which then runs at its default effort. A step that is
|
||||
not linked sets both inside `provider`.
|
||||
# Only the flow-local inputs are always present: a step linked to an `ai_agent` resource
|
||||
# (see `agent`) keeps just those and takes provider/output_type from the resource. Even
|
||||
# `user_message` may be absent, when memory is off and `previous_messages` is the prompt.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -149,6 +149,10 @@ needs becomes unreachable.
|
||||
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
|
||||
- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same
|
||||
two controls through its `model` and `reasoning_effort` inputs, which replace what the agent
|
||||
saved: `"model": { "type": "javascript", "expr": "flow_input.model" }`. An empty value keeps the
|
||||
saved one, and the provider resource always stays the agent's
|
||||
- `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
|
||||
|
||||
Reference in New Issue
Block a user