feat: dynamic ai agent toolsets, and memory as a step input

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-09 17:20:51 +02:00
co-authored by Claude Opus 5
parent 22c1a106cf
commit 4509c96de7
18 changed files with 377 additions and 52 deletions
+1 -1
View File
@@ -1 +1 @@
81edd1382d951265ab3e9b67fc7ca7967676fd56
95889855cde442a520a518056fc4647e6a4b4032
+5
View File
@@ -103,6 +103,7 @@ struct AIAgentArgsRaw {
streaming: Option<bool>,
max_iterations: Option<usize>,
memory: Option<Memory>,
enabled_tools: Option<Vec<String>>,
// Legacy field for backward compatibility
messages_context_length: Option<usize>,
#[serde(default)]
@@ -123,6 +124,9 @@ 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>>,
pub credentials_check: bool,
}
@@ -155,6 +159,7 @@ impl From<AIAgentArgsRaw> for AIAgentArgs {
streaming: raw.streaming,
max_iterations: raw.max_iterations,
memory,
enabled_tools: raw.enabled_tools,
credentials_check: raw.credentials_check.unwrap_or(false),
}
}
+4
View File
@@ -357,6 +357,10 @@ 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
+178 -2
View File
@@ -12,7 +12,10 @@ use async_recursion::async_recursion;
use regex::Regex;
use serde_json::value::RawValue;
use sha2::Digest;
use std::{collections::HashMap, sync::Arc};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use uuid::Uuid;
#[cfg(feature = "bedrock")]
use windmill_ai::ai_bedrock::check_env_credentials;
@@ -49,7 +52,7 @@ use windmill_common::{
utils::{StripPath, HTTP_CLIENT},
worker::{to_raw_value, Connection},
};
use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob};
use windmill_queue::{append_logs, cancel_single_job, CanceledBy, MiniPulledJob};
use crate::{
ai::stream_event_processor::StreamEventProcessor,
@@ -243,6 +246,54 @@ fn overlay_tool_inputs(
}
}
/// 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.
///
/// `None` advertises the whole roster, which is what every agent written before the field existed
/// relies on; an empty list advertises nothing. MCP entries always survive this stage: they
/// advertise `mcp_<server>_<tool>` names that do not exist until the server has answered.
fn narrow_roster(
tools: Vec<AgentTool>,
enabled_tools: Option<&[String]>,
) -> (Vec<AgentTool>, HashSet<String>) {
let Some(enabled) = enabled_tools else {
return (tools, HashSet::new());
};
let mut enabled_mcp_paths = HashSet::new();
let tools = tools
.into_iter()
.filter(|t| {
let named = t
.summary
.as_deref()
.is_some_and(|s| enabled.iter().any(|n| n == s));
match &t.value {
ToolValue::Mcp(mcp) => {
if named {
enabled_mcp_paths
.insert(mcp.resource_path.trim_start_matches("$res:").to_string());
}
true
}
_ => named,
}
})
.collect();
(tools, enabled_mcp_paths)
}
/// Names in `enabled_tools` that name nothing on the agent. The list is an input transform, so it
/// can be computed per run; a name that has since been renamed away must not fail the step, but it
/// would otherwise silently narrow the agent, so the caller logs what it dropped.
fn unmatched_enabled_tools(enabled_tools: &[String], advertised: &[&str]) -> Vec<String> {
enabled_tools
.iter()
.filter(|name| !advertised.contains(&name.as_str()))
.cloned()
.collect()
}
pub async fn handle_ai_agent_job(
// connection
conn: &Connection,
@@ -442,6 +493,19 @@ pub async fn handle_ai_agent_job(
);
}
}
// Flow-local like the two above, but only when the step actually holds a value: an unset
// field arrives as null, and writing that through would drop the `memory` of an agent saved
// back when memory was part of the brain, silently ending the conversations it holds.
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);
}
let args = serde_json::from_value::<AIAgentArgs>(serde_json::Value::Object(brain))
.map_err(|e| {
Error::internal_err(format!(
@@ -477,6 +541,12 @@ pub async fn handle_ai_agent_job(
tools
};
// 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();
let roster_names: Vec<String> = tools.iter().filter_map(|t| t.summary.clone()).collect();
let (tools, enabled_mcp_paths) = narrow_roster(tools, enabled_tools);
// Separate Windmill tools from MCP tools, websearch, and extract MCP resource configs
let mut windmill_modules: Vec<FlowModule> = Vec::new();
// Explicit per-tool descriptions keyed by tool id. When set, these override the
@@ -687,6 +757,47 @@ pub async fn handle_ai_agent_job(
HashMap::new()
};
if let Some(enabled) = enabled_tools {
// The other half of the narrowing above: an MCP tool is advertised when the run names it
// directly, or names the server entry it came from.
tools.retain(|t| match &t.mcp_source {
Some(source) => {
enabled.iter().any(|n| n == &t.def.function.name)
|| enabled_mcp_paths.contains(&source.resource_path)
}
None => true,
});
let mut matchable: Vec<&str> = roster_names.iter().map(|s| s.as_str()).collect();
matchable.extend(
tools
.iter()
.filter(|t| t.mcp_source.is_some())
.map(|t| t.def.function.name.as_str()),
);
windmill_common::feature_usage::log_feature_usage(
"ai_agent",
"dynamic_tools",
if tools.is_empty() && !has_websearch {
"no_tools"
} else {
"tools"
},
);
let unmatched = unmatched_enabled_tools(enabled, &matchable);
if !unmatched.is_empty() {
append_logs(
&job.id,
&job.workspace_id,
format!(
"--- ENABLED TOOLS: {} named no tool of this agent and was ignored ---\n",
unmatched.join(", ")
),
conn,
)
.await;
}
}
let mut inner_occupancy_metrics = occupancy_metrics.clone();
let stream_notifier = StreamNotifier::new(conn, job);
@@ -1845,6 +1956,71 @@ mod tests {
assert!(matches!(&tools[2].value, ToolValue::Mcp(_)));
}
#[test]
fn narrow_roster_keeps_named_tools_and_defers_mcp() {
fn named(id: &str, summary: &str) -> AgentTool {
AgentTool {
id: id.to_string(),
summary: Some(summary.to_string()),
description: None,
value: ToolValue::FlowModule(FlowModuleValue::Script {
input_transforms: HashMap::new(),
path: "u/test/tool".to_string(),
hash: None,
tag_override: None,
is_trigger: None,
pass_flow_input_directly: None,
}),
}
}
fn mcp(id: &str, summary: &str, path: &str) -> AgentTool {
AgentTool {
id: id.to_string(),
summary: Some(summary.to_string()),
description: None,
value: ToolValue::Mcp(windmill_common::flows::McpToolValue {
resource_path: path.to_string(),
include_tools: vec![],
exclude_tools: vec![],
}),
}
}
let roster = || {
vec![
named("a", "get_user"),
named("b", "send_email"),
mcp("c", "github", "$res:u/test/gh"),
]
};
let names = |tools: &[AgentTool]| -> Vec<String> {
tools.iter().filter_map(|t| t.summary.clone()).collect()
};
// No list at all: the whole roster, as every agent written before the field expects.
let (all, paths) = narrow_roster(roster(), None);
assert_eq!(names(&all), ["get_user", "send_email", "github"]);
assert!(paths.is_empty());
// An empty list is a list: it advertises nothing, bar the deferred MCP entry.
let (none, paths) = narrow_roster(roster(), Some(&[]));
assert_eq!(names(&none), ["github"]);
assert!(paths.is_empty());
let enabled = ["get_user".to_string(), "renamed_away".to_string()];
let (kept, paths) = narrow_roster(roster(), Some(&enabled));
assert_eq!(names(&kept), ["get_user", "github"]);
// Named nothing, so the MCP entry only survives to be settled against its own tool names.
assert!(paths.is_empty());
assert_eq!(
unmatched_enabled_tools(&enabled, &["get_user", "send_email", "github"]),
["renamed_away"]
);
// Naming the entry enables every tool of that server, keyed by the path load_mcp_tools uses.
let (_, paths) = narrow_roster(roster(), Some(&["github".to_string()]));
assert_eq!(paths.into_iter().collect::<Vec<_>>(), ["u/test/gh"]);
}
#[test]
fn tool_description_prefers_explicit_over_derived_and_name() {
assert_eq!(
+9 -3
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, memory…) and its tool set. Other flows can link to the same
temperature, output schema…) 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,8 +16,14 @@ 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`) in its own
`input_transforms`; the brain and tools stay in the resource (read-only in the step).
- 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 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
@@ -1080,13 +1080,14 @@
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, whether AI chat skills are turned on or off and how often one is
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, how data tables and their migrations are set up and used, how often
an empty workspace home is seen, how often the home pages create menu and hub-project
picker are opened and from which entry point, and the name of any public hub project
imported from the home page and how far that import got, last 30 days)</li
loaded, whether an AI agent run narrows the tools it may call and whether that leaves
it with none, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and
change a membership, the plan tier and quota shown when the execution meter is opened,
whether app sandbox isolation is turned on, whether a step's workspace script is
edited from the flow editor, how data tables and their migrations are set up and used,
how often an empty workspace home is seen, how often the home pages create menu and
hub-project picker are opened and from which entry point, and the name of any public
hub project imported from the home page and how far that import got, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
@@ -1143,13 +1144,14 @@
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, whether AI chat skills are turned on or off and how often one is
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, how data tables and their migrations are set up and used, how often
an empty workspace home is seen, how often the home pages create menu and hub-project
picker are opened and from which entry point, and the name of any public hub project
imported from the home page and how far that import got, last 30 days)</li
loaded, whether an AI agent run narrows the tools it may call and whether that leaves
it with none, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and
change a membership, the plan tier and quota shown when the execution meter is opened,
whether app sandbox isolation is turned on, whether a step's workspace script is
edited from the flow editor, how data tables and their migrations are set up and used,
how often an empty workspace home is seen, how often the home pages create menu and
hub-project picker are opened and from which entry point, and the name of any public
hub project imported from the home page and how far that import got, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -115,6 +115,14 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
core: true,
virtual: true
},
{
key: 'enabled_tools',
group: 'tools',
label: 'Enabled tools',
tooltip:
'Narrows the tools above to the ones named here, so a run only carries what it needs. Set it to an expression to decide per run. An MCP server named here enables all of its tools.',
defaultHint: 'Default: all of them'
},
{
key: 'max_iterations',
group: 'tools',
@@ -71,15 +71,10 @@ 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: 'Memory', value: 'auto' },
{ label: 'Output schema', value: 'configured' }
])
expect(rows).toEqual([{ label: 'Output schema', value: 'configured' }])
})
})
@@ -143,16 +138,23 @@ describe('nonStaticBrainKeys', () => {
})
describe('flowLocalInputs', () => {
it('keeps only user_message/user_attachments, dropping brain transforms', () => {
it('keeps the steps own inputs, dropping brain transforms', () => {
expect(
flowLocalInputs({
provider: { type: 'static', value: {} },
user_message: { type: 'static', value: 'hi' },
user_attachments: { type: 'static', value: [] }
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 } },
enabled_tools: { type: 'javascript', expr: 'flow_input.tools' }
} as any)
).toEqual({
user_message: { type: 'static', value: 'hi' },
user_attachments: { type: 'static', value: [] }
user_attachments: { type: 'static', value: [] },
memory: { type: 'static', value: { kind: 'auto', context_length: 20 } },
enabled_tools: { type: 'javascript', expr: 'flow_input.tools' }
})
})
@@ -2,21 +2,33 @@ 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 inputs
// (user_message/user_attachments) are intentionally excluded — they are supplied per-flow.
// The brain fields stored flat in an `ai_agent` resource value. The flow-local keys 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',
'max_iterations'
] as const
export const AGENT_FLOW_LOCAL_KEYS = ['user_message', 'user_attachments'] as const
/**
* 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.
*/
export const AGENT_FLOW_LOCAL_KEYS = [
'user_message',
'user_attachments',
'memory',
'enabled_tools'
] as const
export type AgentTool = Record<string, any>
@@ -63,6 +75,37 @@ 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
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
@@ -87,6 +130,8 @@ 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
@@ -170,9 +215,6 @@ export function summarizeAgentBrain(
let value: string
if (key === 'provider') {
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 ?? v.type ?? 'configured') : String(v)
} else if (key === 'output_schema') {
value = 'configured'
} else if (typeof v === 'boolean') {
@@ -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,14 +431,18 @@
return false
}
const cfg = (draft?.args ?? response.value ?? {}) as AIAgentConfig
// 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), ...local }
// 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.
const legacyMemory: Record<string, InputTransform> =
cfg.memory != undefined && !local.memory
? { memory: { type: 'static', value: cfg.memory } as InputTransform }
: {}
const forkedInputs = { ...agentConfigToInputTransforms(cfg), ...legacyMemory, ...local }
const forkedTools = cfg.tools ?? []
inputTransforms = forkedInputs
for (const tool of forkedTools) {
@@ -19,6 +19,7 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { deepEqual } from 'fast-equals'
import { type InputTransform } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
@@ -153,6 +154,21 @@
let schemaProperties = $derived((schema?.properties ?? {}) as Record<string, any>)
// 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.
// A linked step gets the resource's roster here, which is the one it narrows.
$effect(() => {
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 }
}
})
})
let scopedFields = $derived(
AGENT_FIELDS.filter(
(spec) =>
@@ -1232,7 +1232,12 @@
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
workspace={opWs}
visibilityKey={`${$pathStore}:${linkedToolsModuleId}`}
tools={flowModule.value.tools ?? []}
tools={agentLinked
? getLinkedAgentTools(
linkedToolsScope(opWs, $pathStore),
linkedToolsModuleId
)
: (flowModule.value.tools ?? [])}
onSelectTool={noToolNavigation
? undefined
: (toolId) => selectionManager.selectId(toolId, { openPanel: true })}
@@ -151,6 +151,16 @@ 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`).
// 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'
}
},
max_completion_tokens: {
type: 'number',
description: 'The most tokens the answer may use.'
@@ -178,6 +188,7 @@ export const AI_AGENT_SCHEMA: Schema = {
'memory',
'output_schema',
'user_attachments',
'enabled_tools',
'max_completion_tokens',
'temperature',
'max_iterations'
@@ -291,7 +302,10 @@ export async function loadSchemaFromModule(
}
return accu
}, {}),
schema: AI_AGENT_SCHEMA
// A copy per step, never the shared constant: the form writes back into the property it
// renders (`InputTransformForm` binds `schema.properties[argName]`), and the tool names
// one step offers would otherwise become every step's.
schema: structuredClone(AI_AGENT_SCHEMA)
}
}
@@ -69,6 +69,39 @@ 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 { flowLocalInputs, type AIAgentConfig } from './agentResourceUtils'
import { overridingFlowLocalInputs, 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),
...flowLocalInputs(value.input_transforms as Record<string, InputTransform>)
...overridingFlowLocalInputs(value.input_transforms as Record<string, InputTransform>)
}
} as AiAgentValue
}
+8
View File
@@ -1060,6 +1060,14 @@ components:
Array of file references (images or PDFs) for the AI agent.
Format: Array<{ bucket: string, key: string }> - S3 object references
Example: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]
enabled_tools:
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']
max_completion_tokens:
allOf:
- $ref: '#/components/schemas/InputTransform'