refactor: tag enabled_tools and drop the memory step input

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-11 21:26:21 +02:00
co-authored by Claude Opus 5
parent 76e70d6a8f
commit 38e80bbb26
20 changed files with 179 additions and 247 deletions
+16 -4
View File
@@ -74,6 +74,19 @@ impl Default for OutputType {
}
}
/// Which of the agent's tools a run may call.
#[derive(Deserialize, Debug, Clone)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum EnabledTools {
All,
Only {
/// By the name the model is shown, so an MCP tool is `mcp_<server>_<tool>`. Naming the MCP
/// server entry instead enables every tool it exposes. Empty advertises nothing.
#[serde(default)]
tools: Vec<String>,
},
}
#[derive(Deserialize, Debug, Clone)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Memory {
@@ -103,7 +116,7 @@ struct AIAgentArgsRaw {
streaming: Option<bool>,
max_iterations: Option<usize>,
memory: Option<Memory>,
enabled_tools: Option<Vec<String>>,
enabled_tools: Option<EnabledTools>,
// Legacy field for backward compatibility
messages_context_length: Option<usize>,
#[serde(default)]
@@ -124,9 +137,8 @@ pub struct AIAgentArgs {
pub streaming: Option<bool>,
pub max_iterations: Option<usize>,
pub memory: Option<Memory>,
/// Names of the tools the agent may call this run. `None` advertises the whole roster; an
/// empty list advertises nothing.
pub enabled_tools: Option<Vec<String>>,
/// Which of the agent's tools this run may call. `None` is the whole roster, as `All` is.
pub enabled_tools: Option<EnabledTools>,
pub credentials_check: bool,
}
-4
View File
@@ -357,10 +357,6 @@ fn config_to_draft(value: serde_json::Value) -> Result<AgentDraft> {
Some(serde_json::Value::Array(tools)) => tools,
_ => vec![],
};
// Conversation history belongs to the flow running the agent, not to the agent, and an agent
// saved before that was true still carries one. Every case must start from the same blank
// state, so it is dropped here rather than replayed into each of them.
config.remove("memory");
// Every brain key becomes a static transform: `$res:`/`$var:` in them are resolved by the
// same argument machinery a linked step's resource goes through.
let input_transforms = config
+17 -70
View File
@@ -252,34 +252,6 @@ fn overlay_tool_inputs(
/// `build_args_map` has already resolved them, so passing them through it again would expand
/// contextual values — `$WM_TOKEN` in a user message would reach the model provider.
///
/// `user_message`/`user_attachments` are the step's whatever it holds, blank included. The two
/// below them are only an override when the step actually holds a value: an unfilled field arrives
/// as null, and writing that through would drop the `memory` of an agent saved back when memory was
/// part of the brain, ending the conversations it holds without saying so.
fn overlay_flow_local_args(
brain: &mut serde_json::Map<String, serde_json::Value>,
local_args: &HashMap<String, Box<RawValue>>,
) {
for key in ["user_message", "user_attachments"] {
if let Some(v) = local_args.get(key) {
brain.insert(
key.to_string(),
serde_json::from_str(v.get()).unwrap_or(serde_json::Value::Null),
);
}
}
for key in ["memory", "enabled_tools"] {
let Some(v) = local_args
.get(key)
.and_then(|v| serde_json::from_str::<serde_json::Value>(v.get()).ok())
.filter(|v| !v.is_null())
else {
continue;
};
brain.insert(key.to_string(), v);
}
}
/// The roster a run advertises, given the tool names it enabled, plus the resource paths of the
/// MCP entries it named outright — those enable every tool of that server, which only
/// `load_mcp_tools` can enumerate.
@@ -565,7 +537,17 @@ pub async fn handle_ai_agent_job(
)))
}
};
overlay_flow_local_args(&mut brain, &local_args);
// Only after interpolating the resource: these are caller-controlled and already resolved by
// build_args_map, so passing them through it again would expand contextual values —
// `$WM_TOKEN` in a user message would reach the model provider.
for key in ["user_message", "user_attachments", "enabled_tools"] {
if let Some(v) = local_args.get(key) {
brain.insert(
key.to_string(),
serde_json::from_str(v.get()).unwrap_or(serde_json::Value::Null),
);
}
}
let args = serde_json::from_value::<AIAgentArgs>(serde_json::Value::Object(brain))
.map_err(|e| {
Error::internal_err(format!(
@@ -602,8 +584,12 @@ pub async fn handle_ai_agent_job(
};
// Narrow the roster to the tools this run enabled, before the loop below pays a script or hub
// fetch per tool.
let enabled_tools = args.enabled_tools.as_deref();
// fetch per tool. Everything downstream works on the names alone: `All` and an absent field
// are the same run.
let enabled_tools = match args.enabled_tools.as_ref() {
Some(EnabledTools::Only { tools }) => Some(tools.as_slice()),
Some(EnabledTools::All) | None => None,
};
let roster_names: Vec<String> = tools.iter().filter_map(|t| t.summary.clone()).collect();
let (tools, enabled_mcp_paths) = narrow_roster(tools, enabled_tools);
@@ -2113,45 +2099,6 @@ mod tests {
));
}
/// The rule the whole back-compat story rests on: an unfilled step field arrives as null, and
/// writing it through would end the conversations a legacy agent's own `memory` holds.
#[test]
fn flow_local_args_override_the_agent_only_when_set() {
fn raw(json: &str) -> Box<RawValue> {
RawValue::from_string(json.to_string()).unwrap()
}
let mut brain = serde_json::Map::new();
brain.insert("system_prompt".to_string(), serde_json::json!("from agent"));
brain.insert("memory".to_string(), serde_json::json!({ "kind": "auto" }));
let local_args = HashMap::from([
(
"user_message".to_string(),
raw("\"ask the flow's question\""),
),
("memory".to_string(), raw("null")),
("enabled_tools".to_string(), raw("null")),
]);
overlay_flow_local_args(&mut brain, &local_args);
assert_eq!(
brain["user_message"],
serde_json::json!("ask the flow's question")
);
assert_eq!(brain["system_prompt"], serde_json::json!("from agent"));
// Unfilled, so the agent's own is what runs.
assert_eq!(brain["memory"], serde_json::json!({ "kind": "auto" }));
assert!(!brain.contains_key("enabled_tools"));
let local_args = HashMap::from([
("memory".to_string(), raw("{\"kind\":\"off\"}")),
("enabled_tools".to_string(), raw("[\"get_user\"]")),
]);
overlay_flow_local_args(&mut brain, &local_args);
assert_eq!(brain["memory"], serde_json::json!({ "kind": "off" }));
assert_eq!(brain["enabled_tools"], serde_json::json!(["get_user"]));
}
#[test]
fn tool_description_prefers_explicit_over_derived_and_name() {
assert_eq!(
+7 -10
View File
@@ -2,7 +2,7 @@
An AI agent flow step can be saved as a **reusable agent** — a resource of the built-in
`ai_agent` resource type that bundles the agent's brain (provider/model, system prompt,
temperature, output schema…) and its tool set. Other flows can link to the same
temperature, output schema, memory…) and its tool set. Other flows can link to the same
agent, and edits to the agent propagate to every linked step.
The `ai_agent` resource type is defined in the hub (windmill-integrations) and synced into
@@ -16,14 +16,11 @@ every workspace via the standard cached-resource-type sync, like other built-in
- The brain config and tools are resolved at runtime from the resource
(`windmill-worker/src/ai_executor.rs`): the brain is interpolated, so a nested provider `$res:`
credential resolves automatically.
- The step keeps only the flow-local inputs (`user_message`, `user_attachments`, `memory`,
`enabled_tools`) in its own `input_transforms`; the brain and tools stay in the resource
(read-only in the step). `memory` is one of them because a conversation belongs to the flow
having it, not to an agent reused across flows: it is identified by a `memory_id` minted per
step on flow save, so two flows linking one agent cannot answer from each other's history.
`enabled_tools` names the tools of the roster this step may call, narrowing one use of a shared
agent without touching the agent. An agent saved before `memory` moved still carries one, which
the worker honours while the step sets none; unlinking such an agent copies it onto the step.
- The step keeps only the flow-local inputs (`user_message`, `user_attachments`, `enabled_tools`)
in its own `input_transforms`; the brain and tools stay in the resource (read-only in the step).
`enabled_tools` says which of the roster this step may call, narrowing one use of a shared agent
without touching the agent: `{kind: 'all'}` as an absent field does, `{kind: 'only', tools: [...]}`
for a list, tagged like `memory` so the form reads it the same way.
- The agent carries its tools' default input bindings verbatim as authored (static, AI-filled,
or flow expressions), so saving round-trips losslessly. Each host flow overrides what it
needs: `tool_inputs` stores per-tool overrides (a diff from the resource tool's own
@@ -51,7 +48,7 @@ A flow does not wait for that deploy to see the draft:
- Testing the flow, or a single linked step, runs the draft. `runFlowPreview` and `ModuleTest`
substitute each linked step for the standalone step the draft would run as
(`linkedAgentDrafts.ts`): `agent` cleared, the draft's brain as static input transforms, the
draft's tools on the step, and the step's own `user_message`/`user_attachments` kept on top —
draft's tools on the step, and the step's own flow-local inputs kept on top —
the same overlay order `ai_executor.rs` applies to a linked step. `tool_inputs` is untouched,
since the worker overlays it in both branches.
- The step's linked card and the graph's tool nodes show the draft, with a *Draft* badge, so the
@@ -21,6 +21,10 @@
class?: string
onJobDone?: () => void
hideRunButton?: boolean
/** Passed through to the form: the step whose agent form this preview accompanies. */
openFieldsKey?: string
/** Passed through to the form: fields it must offer whatever the step holds. */
runInputKeys?: readonly string[]
}
let {
@@ -34,7 +38,9 @@
focusArg = undefined,
class: className = '',
onJobDone,
hideRunButton = false
hideRunButton = false,
openFieldsKey = undefined,
runInputKeys = undefined
}: Props = $props()
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -85,5 +91,5 @@
</div>
{/if}
<ModulePreviewForm {pickableProperties} {mod} {schema} {focusArg} />
<ModulePreviewForm {pickableProperties} {mod} {schema} {focusArg} {openFieldsKey} {runInputKeys} />
</div>
@@ -15,6 +15,7 @@
import { twMerge } from 'tailwind-merge'
import { workspaceStore } from '$lib/stores'
import { AGENT_FIELDS, initialVisibleAgentFields } from './flows/agentFormFields'
import { openAgentFields } from './flows/content/AiAgentStepInputs.svelte'
interface Props {
schema: Schema | { properties?: Record<string, any>; required?: string[] }
@@ -23,6 +24,12 @@
isValid?: boolean
autofocus?: boolean
focusArg?: string
/** Identifies the step whose agent form this one accompanies, so it can offer the fields that
* form has open. Same key `AiAgentStepInputs` is given. */
openFieldsKey?: string
/** Fields to offer whatever the step holds, for a surface where nothing else can set them
* (`AGENT_EDITOR_RUN_INPUTS`). */
runInputKeys?: readonly string[]
}
let {
@@ -31,7 +38,9 @@
pickableProperties,
isValid = $bindable(true),
autofocus = false,
focusArg = undefined
focusArg = undefined,
openFieldsKey = undefined,
runInputKeys = []
}: Props = $props()
const { stepsInputArgs, flowStateStore, flowStore, previewArgs, opWorkspace } =
@@ -46,10 +55,11 @@
/** An agent asks for the same fields here that its own form shows: a setting the step leaves
* unset is not something a run needs told, and listing all eleven buries the message under the
* configuration. What the step configures stays, as it does on any other step. A schema key the
* field registry doesn't know is kept, so a new one is never silently dropped. A run input is
* kept whatever the step holds: this form has no add-field control, so hiding one would leave
* no way at all to supply it. */
* configuration. What the step configures stays, as it does on any other step, along with the
* rows its form has open — a field added there and left at its default reads as unset from the
* transforms alone, and this form has no add-field control to get it back. `runInputKeys` is
* for a surface whose form cannot open a row at all. A schema key the field registry doesn't
* know is kept, so a new one is never silently dropped. */
let schemaKeys = $derived(Object.keys(schema?.properties ?? {}))
let visibleKeys = $derived.by(() => {
@@ -58,7 +68,9 @@
const transforms = (mod.value as { input_transforms?: Record<string, unknown> })
?.input_transforms
const visible = initialVisibleAgentFields(transforms, schema?.properties)
const known = new Set(AGENT_FIELDS.filter((f) => !f.runInput).map((f) => f.key))
for (const key of openAgentFields(openFieldsKey)) visible.add(key)
for (const key of runInputKeys) visible.add(key)
const known = new Set(AGENT_FIELDS.map((f) => f.key))
return all.filter((key) => !known.has(key) || visible.has(key))
})
@@ -173,14 +173,8 @@
// `args` is built from the whole AI agent schema whatever the step is, so on a linked step
// it carries every brain key as undefined even though the form renders only the flow-local
// ones (`flowLocalAgentSchema`). Overlaying those would shadow the brain the draft just
// supplied with nothing, so an inlined step takes only the inputs its form actually offers
// — and of those, only the ones it was given a value for. An unfilled field must inherit
// what the agent carries, the way a deployed run does: an agent saved before `memory`
// became a step input still holds one, and a test that blanked it would answer without the
// history the same step answers with when the flow runs.
const formKeys = draft
? AGENT_FLOW_LOCAL_KEYS.filter((key) => args?.[key] !== undefined)
: Object.keys(args)
// supplied with nothing, so an inlined step takes only the inputs its form actually offers.
const formKeys = draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args)
// The test form only covers the schema it was given, and for a standalone agent that may be
// the flow-local one (the agent editor shows the brain in its own form, not here). Take the
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -44,10 +44,6 @@ export interface AgentFieldSpec {
defaultHint?: string
/** Ignored for image output, so the field hides while `output_type` is `'image'`. */
textOnly?: boolean
/** Filled in per run rather than configured on the step, so a form that is collecting a run's
* inputs shows it whether or not the step wrote anything for it. The step's own form still
* treats it as optional: there it is one of the fields the add menu offers. */
runInput?: boolean
}
export const AGENT_FIELDS: AgentFieldSpec[] = [
@@ -105,8 +101,7 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
label: 'Attachments',
tooltip: 'Images or PDFs sent along with the user message. Needs S3 storage on the workspace.',
implicit: [],
defaultHint: 'Default: none',
runInput: true
defaultHint: 'Default: none'
},
{
key: AGENT_TOOLS_ROW,
@@ -120,7 +115,8 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
group: 'tools',
label: 'Enabled tools',
tooltip:
'Narrows the tools above to the ones named here, so a run only carries what it needs. Leave it empty for no tools at all, or set it to an expression to decide per run. An MCP server named here enables every tool it exposes; an expression can name one of them on its own, as mcp_<server>_<tool>.',
'Whether a run carries every tool above or only the ones listed, so it costs no more than it needs. Listing none at all leaves the agent with no tools. Set it to an expression to decide per run. An MCP server listed here enables every tool it exposes, and an expression can name a single one of them as mcp_<server>_<tool>.',
implicit: { kind: 'all' },
defaultHint: 'Default: all of them'
},
{
@@ -164,6 +160,16 @@ export const AGENT_FIELD_BY_KEY: Record<string, AgentFieldSpec> = Object.fromEnt
AGENT_FIELDS.map((f) => [f.key, f])
)
/**
* Fields the agent editor's test form has to offer whatever the agent holds, rather than only the
* ones a step wrote: a saved agent stores no flow-local input, so its own form cannot open a row
* for one and the test form is the only place left to supply it.
*
* `enabled_tools` stays out because narrowing a roster belongs to the step that reuses the agent,
* not to a run of the agent itself.
*/
export const AGENT_EDITOR_RUN_INPUTS: readonly string[] = ['user_attachments']
/**
* Whether a transform holds something a run would do differently from an absent key. Core fields
* are always set: they are what an agent is.
@@ -71,10 +71,15 @@ describe('summarizeAgentBrain', () => {
})
it('summarizes structured fields compactly', () => {
// memory is serialized with a `kind` tag (serde tag = "kind")
const rows = summarizeAgentBrain({
memory: { kind: 'auto', context_length: 20 } as any,
output_schema: { type: 'object' } as any
})
expect(rows).toEqual([{ label: 'Output schema', value: 'configured' }])
expect(rows).toEqual([
{ label: 'Memory', value: 'auto' },
{ label: 'Output schema', value: 'configured' }
])
})
})
@@ -144,16 +149,13 @@ describe('flowLocalInputs', () => {
provider: { type: 'static', value: {} },
user_message: { type: 'static', value: 'hi' },
user_attachments: { type: 'static', value: [] },
// Both belong to the use, not to the reused agent: history is keyed by a memory_id
// minted per step, and the enabled set narrows one flow's use of a shared roster.
// Saving either into the resource would share it across every flow linking it.
memory: { type: 'static', value: { kind: 'auto', context_length: 20 } },
// The roster it narrows belongs to the agent, but which of it one flow may call does
// not: saving this into the resource would impose it on every flow linking the agent.
enabled_tools: { type: 'javascript', expr: 'flow_input.tools' }
} as any)
).toEqual({
user_message: { type: 'static', value: 'hi' },
user_attachments: { type: 'static', value: [] },
memory: { type: 'static', value: { kind: 'auto', context_length: 20 } },
enabled_tools: { type: 'javascript', expr: 'flow_input.tools' }
})
})
@@ -2,13 +2,14 @@ import { deepEqual } from 'fast-equals'
import type { InputTransform } from '$lib/gen'
import { AGENT_FIELDS } from './agentFormFields'
// The brain fields stored flat in an `ai_agent` resource value. The flow-local keys below are
// The brain fields stored flat in an `ai_agent` resource value. The flow-local inputs below are
// intentionally excluded — they are supplied per-flow.
export const AGENT_BRAIN_KEYS = [
'provider',
'output_type',
'system_prompt',
'streaming',
'memory',
'output_schema',
'max_completion_tokens',
'temperature',
@@ -18,17 +19,10 @@ export const AGENT_BRAIN_KEYS = [
/**
* The inputs a step supplies for itself, whether or not it is linked to a saved agent.
*
* `memory` is one of them because conversation history belongs to the flow having the
* conversation, not to an agent reused across flows: it is identified by a `memory_id` minted per
* step on flow save, and two flows linking one agent must not answer from each other's history.
* `enabled_tools` likewise narrows one use of an agent, leaving the roster it narrows alone.
* `enabled_tools` is one of them because it narrows one use of an agent rather than the agent:
* saving it into the resource would impose one flow's roster on every flow linking it.
*/
export const AGENT_FLOW_LOCAL_KEYS = [
'user_message',
'user_attachments',
'memory',
'enabled_tools'
] as const
export const AGENT_FLOW_LOCAL_KEYS = ['user_message', 'user_attachments', 'enabled_tools'] as const
export type AgentTool = Record<string, any>
@@ -75,40 +69,6 @@ export function flowLocalInputs(
return out
}
/**
* Whether a transform holds something a run would use. A field the form has not been filled in for
* is seeded as `{"type":"static"}` — and comes back from the API with an explicit null — which a
* run cannot tell from an absent key.
*/
function transformIsSet(transform: InputTransform | undefined): boolean {
if (!transform) return false
const t = transform as any
// Same reading of "set" as `agentFieldIsSet`: an emptied expression is a field being written,
// not one holding a value.
if (t.type === 'javascript') return Boolean(t.expr)
if (t.type !== 'static') return true
return t.value !== undefined && t.value !== null
}
/**
* `flowLocalInputs`, minus the fields the step is holding a placeholder for. Use it wherever the
* step's inputs are laid over a value the agent supplied: an unfilled field must not shadow what it
* inherits, which is the rule the worker follows too (`ai_executor.rs` writes the step's `memory`
* over the resource's only when it is not null).
*/
export function overridingFlowLocalInputs(
inputTransforms: Record<string, InputTransform> | undefined
): Record<string, InputTransform> {
const out: Record<string, InputTransform> = {}
for (const key of AGENT_FLOW_LOCAL_KEYS) {
const transform = inputTransforms?.[key]
if (transformIsSet(transform)) {
out[key] = transform!
}
}
return out
}
/**
* The host-flow overrides to store on a linked step for one tool: the subset of the tool's edited
* input_transforms that diverges from the resource tool's own transforms. Storing only the diff (not
@@ -133,8 +93,6 @@ export interface AIAgentConfig {
output_type?: string
system_prompt?: string
streaming?: boolean
/** Only on an agent saved while memory was still a brain field. Nothing writes it any more, and
* the worker honours it only while the step using it sets no memory of its own. */
memory?: unknown
output_schema?: unknown
max_completion_tokens?: number
@@ -206,20 +164,13 @@ export function transformValuedBrainKeys(args: Record<string, any> | undefined):
})
}
/**
* Flatten a saved agent's brain config into human-readable label/value rows for a read-only display
* on a linked step. Only set fields are returned, in the canonical brain-key order.
*
* `memory` is listed after them although it is no longer a brain field, because an agent saved
* while it was one still carries a config the worker honours. Nothing writes one any more, so the
* row only ever appears on such an agent — and where it does, the step's own Memory field would
* otherwise be the only thing on screen saying anything about memory, while reading "off".
*/
/** Flatten a saved agent's brain config into human-readable label/value rows for a read-only
* display on a linked step. Only set fields are returned, in the canonical brain-key order. */
export function summarizeAgentBrain(
config: AIAgentConfig | undefined
): { label: string; value: string }[] {
const rows: { label: string; value: string }[] = []
for (const key of [...AGENT_BRAIN_KEYS, 'memory']) {
for (const key of AGENT_BRAIN_KEYS) {
const v = (config as any)?.[key]
if (v === undefined || v === null || v === '') continue
let value: string
@@ -227,7 +178,7 @@ export function summarizeAgentBrain(
value = [v.kind, v.model].filter(Boolean).join(' · ') || 'configured'
} else if (key === 'memory') {
// Memory configs are serialized with a `kind` tag (serde tag = "kind").
value = typeof v === 'object' ? (v.kind ?? 'configured') : String(v)
value = typeof v === 'object' ? (v.kind ?? v.type ?? 'configured') : String(v)
} else if (key === 'output_schema') {
value = 'configured'
} else if (typeof v === 'boolean') {
@@ -30,7 +30,7 @@
type AIAgentConfig
} from '../agentResourceUtils'
import { agentArgsToTransforms } from '../linkedAgentDrafts'
import { AGENT_TOOLS_ROW } from '../agentFormFields'
import { AGENT_EDITOR_RUN_INPUTS, AGENT_TOOLS_ROW } from '../agentFormFields'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import { useAgentDraft } from '../agentDraft.svelte'
@@ -392,6 +392,7 @@
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
@@ -10,9 +10,9 @@
import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte'
import {
AGENT_BRAIN_KEYS,
AGENT_FLOW_LOCAL_KEYS,
agentConfigToInputTransforms,
flowLocalInputs,
overridingFlowLocalInputs,
inputTransformsToAgentConfig,
nonStaticBrainKeys,
summarizeAgentBrain,
@@ -431,21 +431,14 @@
return false
}
const cfg = (draft?.args ?? response.value ?? {}) as AIAgentConfig
// Preserve the flow-local inputs already wired in the step. Only the ones it actually holds a
// value for: an unfilled field is seeded as a placeholder transform, which would otherwise
// read as an override and shadow what the agent supplies below.
const local = overridingFlowLocalInputs(inputTransforms)
// `memory` is the step's, but an agent saved back when it was part of the brain still carries
// one that the worker honours while the step holds none. Forking is where that ends, so it
// comes across as the step's own rather than being dropped with the link — without the
// `memory_id`, so that saving mints one per step (`cleanInputs`) instead of leaving every
// flow that forked this agent answering from a single shared conversation.
const legacyMemory: Record<string, InputTransform> = {}
if (cfg.memory != undefined && !local.memory) {
const { memory_id: _minted, ...rest } = cfg.memory as Record<string, unknown>
legacyMemory.memory = { type: 'static', value: rest } as InputTransform
// Preserve the flow-local inputs already wired in the step.
const local: Record<string, InputTransform> = {}
for (const key of AGENT_FLOW_LOCAL_KEYS) {
if (inputTransforms?.[key]) {
local[key] = inputTransforms[key]
}
}
const forkedInputs = { ...agentConfigToInputTransforms(cfg), ...legacyMemory, ...local }
const forkedInputs = { ...agentConfigToInputTransforms(cfg), ...local }
const forkedTools = cfg.tools ?? []
inputTransforms = forkedInputs
for (const tool of forkedTools) {
@@ -15,6 +15,15 @@
openFieldsByStep.delete(oldest)
}
}
/**
* The rows this step's form has open, for the run form, which has no add-field control of its
* own and would otherwise not offer a field that was added here and left at its default: to a
* reader of the stored transforms alone, that is indistinguishable from a field nobody touched.
*/
export function openAgentFields(key: string | undefined): string[] {
return (key ? openFieldsByStep.get(key) : undefined) ?? []
}
</script>
<script lang="ts">
@@ -162,9 +171,10 @@
const names = tools.map((tool) => tool.summary).filter((name): name is string => !!name)
const properties = schemaProperties
untrack(() => {
const property = properties['enabled_tools']
if (property && !deepEqual(property.items?.enum, names)) {
property.items = { ...(property.items ?? { type: 'string' }), enum: names }
const list = properties['enabled_tools']?.oneOf?.find((variant) => variant.title === 'only')
?.properties?.tools
if (list && !deepEqual(list.items?.enum, names)) {
list.items = { ...(list.items ?? { type: 'string' }), enum: names }
}
})
})
@@ -163,6 +163,9 @@
parentModule?.value?.type === 'aiagent' ? `${parentModule.id}/${flowModule.id}` : flowModule.id
)
// Which step's open agent fields to remember, and which the test form reads back.
let agentFieldsKey = $derived(`${$pathStore}:${linkedToolsModuleId}`)
let workspaceScriptTag: string | undefined = $state(undefined)
let workspaceScriptLang: ScriptLang | undefined = $state(undefined)
let diffMode = $state(false)
@@ -1231,7 +1234,7 @@
helperScript={retrieveDynCodeAndLang(flowModule.value)}
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
workspace={opWs}
visibilityKey={`${$pathStore}:${linkedToolsModuleId}`}
visibilityKey={agentFieldsKey}
tools={agentLinked
? getLinkedAgentTools(
linkedToolsScope(opWs, $pathStore),
@@ -1341,6 +1344,7 @@
focusArg={highlightArg}
{onJobDone}
hideRunButton={debugMode && isDebuggableScript}
openFieldsKey={agentFieldsKey}
/>
{:else if visibleSelected === 'chat' && canShowChatTab && flowModule.value.type === 'aiagent'}
<div class="flex-1 overflow-auto p-4">
@@ -151,15 +151,47 @@ export const AI_AGENT_SCHEMA: Schema = {
resourceType: 's3object'
}
},
// The step's own roster fills `items.enum` in, so the static editor offers the tools this
// agent actually has (`AiAgentStepInputs`).
// Tagged like `memory` so the form reads the same way: the variant says whether a run carries
// the whole roster or a list, and an empty list under `only` is a choice rather than a field
// nobody filled in. The step's own roster fills the list's `items.enum` in, so the static
// editor offers the tools this agent actually has (`AiAgentStepInputs`).
// Shown for image output as the roster it narrows is, even though neither is used there.
enabled_tools: {
type: 'array',
description: 'The tools the agent may call, by name. All of them when unset.',
items: {
type: 'string'
}
type: 'object',
description: 'Which of the agent tools a run may call.',
oneOf: [
{
type: 'object',
title: 'all',
properties: {
kind: {
type: 'string',
enum: ['all'],
description: 'Carry every tool the agent has'
}
}
},
{
type: 'object',
title: 'only',
properties: {
kind: {
type: 'string',
enum: ['only'],
description: 'Carry only the tools listed'
},
tools: {
type: 'array',
description:
'Tools by the name the model is shown. An MCP server enables every tool it exposes.',
items: {
type: 'string'
}
}
},
required: ['kind']
}
]
},
max_completion_tokens: {
type: 'number',
@@ -69,39 +69,6 @@ describe('inlineAgentDraft', () => {
user_message: { type: 'static', value: 'hi' }
})
})
// Every step carries a placeholder transform for each field its form has not filled in. It reads
// as an override unless it is recognised as unset, which would run a preview without the memory
// an agent saved before memory moved onto the step still carries — and a deployed run with it.
it('lets an unfilled flow-local field inherit from the draft', () => {
const inlined = inlineAgentDraft(
linkedStep({
user_message: { type: 'static', value: 'hi' },
memory: { type: 'static', value: undefined }
}),
{ memory: { kind: 'auto', context_length: 20 } } as any
)
expect(inlined.input_transforms?.memory).toEqual({
type: 'static',
value: { kind: 'auto', context_length: 20 }
})
})
it('lets a filled flow-local field override the draft', () => {
const inlined = inlineAgentDraft(
linkedStep({
user_message: { type: 'static', value: 'hi' },
memory: { type: 'static', value: { kind: 'off' } }
}),
{ memory: { kind: 'auto', context_length: 20 } } as any
)
expect(inlined.input_transforms?.memory).toEqual({
type: 'static',
value: { kind: 'off' }
})
})
})
describe('inlineAgentDrafts', () => {
@@ -10,7 +10,7 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { canWrite } from '$lib/utils'
import type { UserExt } from '$lib/stores'
import { dfs } from './dfs'
import { overridingFlowLocalInputs, type AIAgentConfig } from './agentResourceUtils'
import { flowLocalInputs, type AIAgentConfig } from './agentResourceUtils'
import type { AgentResourceState } from './agentDraft.svelte'
import type { AgentTool } from './agentToolUtils'
@@ -188,7 +188,7 @@ export function inlineAgentDraft(value: AiAgentValue, args: AIAgentConfig): AiAg
tools: (args.tools ?? []) as AgentTool[],
input_transforms: {
...agentArgsToTransforms(args),
...overridingFlowLocalInputs(value.input_transforms as Record<string, InputTransform>)
...flowLocalInputs(value.input_transforms as Record<string, InputTransform>)
}
} as AiAgentValue
}
+7 -5
View File
@@ -1064,10 +1064,12 @@ components:
allOf:
- $ref: '#/components/schemas/InputTransform'
description: |
Array of strings naming the tools the agent may call this run, out of the ones
configured in `tools`. Every tool when unset, none when empty.
An MCP server named here enables all of the tools it exposes.
Example: ['get_user', 'send_email']
Which of the tools configured in `tools` the agent may call this run, as a tagged
object: { kind: 'all' } carries every one of them, as leaving this unset does, and
{ kind: 'only', tools: [...] } carries only the ones named — none when that list is
empty. Tools are named as the model is shown them, so an MCP tool is
`mcp_<server>_<tool>`; naming the MCP server instead enables every tool it exposes.
Example: { kind: 'only', tools: ['get_user', 'send_email'] }
max_completion_tokens:
allOf:
- $ref: '#/components/schemas/InputTransform'
@@ -1115,7 +1117,7 @@ components:
Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain
config (provider/model/system prompt/etc.) and tool set are resolved at runtime from
that resource; the module's input_transforms then only carry the flow-local inputs
(user_message/user_attachments/memory/enabled_tools).
(user_message/user_attachments/enabled_tools).
tool_inputs:
type: object
description: |