mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
22c1a106cf
commit
4509c96de7
@@ -1 +1 @@
|
||||
81edd1382d951265ab3e9b67fc7ca7967676fd56
|
||||
95889855cde442a520a518056fc4647e6a4b4032
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 page’s 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 page’s 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 page’s 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 page’s 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 step’s 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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user