mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: dynamic AI agent toolsets (#11050)
* feat: dynamic ai agent toolsets, and memory as a step input Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address review round 1 on dynamic ai agent toolsets Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: tag enabled_tools and drop the memory step input Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: let an mcp server entry be named by the path the roster shows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep $res: out of the tool names the enabled tools picker offers Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: name an mcp server by its bare path on the one side that can hold it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count the enabled tool names that matched nothing instead of logging them Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: narrow an agent's roster in one pass, by whole entries Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin that an mcp summary is rejected against a name that is not Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: regenerate the copilot flow schema after the merge Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: shorten the enabled tools list hint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: take enabled_tools back to a plain list of tool names Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: keep the enabled tools add-menu hint describing the unset field Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: name a websearch tool that carries no summary of its own Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: reserve the name web search is enabled by so no tool can share it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: spell the reserved web search name with a hyphen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: reserve __wm_web_search as the name web search is enabled by Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: advance ee-repo-ref past the git sync ci check work Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: shorten the enabled tools description the run form shows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to e4c1b794d6c5e6e390987341b2840587bbb40348 This commit updates the EE repository reference after PR #785 was merged in windmill-ee-private. Previous ee-repo-ref: af668462f0f06b02a5f4e0c22e6156858487a518 New ee-repo-ref: e4c1b794d6c5e6e390987341b2840587bbb40348 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
Ruben Fiszel
windmill-internal-app[bot]
parent
42f489685b
commit
a78beff743
@@ -1 +1 @@
|
||||
ccada062c072d7b74894b63863728fd1ef9bdffd
|
||||
ccada062c072d7b74894b63863728fd1ef9bdffd
|
||||
@@ -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>,
|
||||
/// Which of the agent's tools this run may call; `narrow_roster` holds what the names are and
|
||||
/// what `None` means.
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,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 +243,88 @@ fn overlay_tool_inputs(
|
||||
}
|
||||
}
|
||||
|
||||
/// What every websearch entry is named by, whatever label it carries. Web search reaches the model
|
||||
/// as a provider capability rather than a tool, so it has no model-facing name of its own, and the
|
||||
/// editor's label is not one: a flow module tool could carry the same one, and enabling that tool
|
||||
/// would then silently turn web search on with it.
|
||||
///
|
||||
/// Reserved, on the `__wm_` prefix this codebase uses for names it keeps for itself, and held that
|
||||
/// way by `flow_module_tool_name` refusing to advertise a tool that takes it.
|
||||
const WEBSEARCH_ENABLED_NAME: &str = "__wm_web_search";
|
||||
|
||||
/// The name a flow module tool is advertised to the model under.
|
||||
///
|
||||
/// Rejected rather than skipped: a tool the model is never shown is a tool the agent silently does
|
||||
/// not have, and a run that quietly drops one is harder to explain than a run that will not start.
|
||||
fn flow_module_tool_name(summary: Option<&str>) -> Result<&str, Error> {
|
||||
match summary {
|
||||
Some(name) if name == WEBSEARCH_ENABLED_NAME => Err(Error::internal_err(format!(
|
||||
"Invalid tool name: {name:?} is reserved for enabling web search"
|
||||
))),
|
||||
Some(name) if TOOL_NAME_REGEX.is_match(name) => Ok(name),
|
||||
other => Err(Error::internal_err(format!("Invalid tool name: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name a run enables a roster entry by: the name the model is shown, except for an entry the
|
||||
/// model is shown nothing of, which cannot be named by a label others may share. An MCP server is
|
||||
/// named by the resource it points at, web search by `WEBSEARCH_ENABLED_NAME`.
|
||||
///
|
||||
/// The MCP path is bare. The roster stores it as authored, `$res:` and all, but a name is an
|
||||
/// argument value and one carrying that prefix is resolved to the resource itself before the worker
|
||||
/// is handed its args, so the prefixed form is not something the list can hold.
|
||||
fn tool_enabled_name(tool: &AgentTool) -> Option<&str> {
|
||||
match &tool.value {
|
||||
ToolValue::Mcp(mcp) => Some(mcp.resource_path.trim_start_matches("$res:")),
|
||||
ToolValue::Websearch(_) => Some(WEBSEARCH_ENABLED_NAME),
|
||||
_ => tool.summary.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The roster a run advertises, given the names it enabled.
|
||||
///
|
||||
/// `None` advertises the whole roster, which is what every agent written before the field existed
|
||||
/// relies on; an empty list advertises nothing.
|
||||
///
|
||||
/// Whole entries, decided before any of them is resolved: an MCP server a run switched off is never
|
||||
/// contacted, and reading its resource, refreshing its token and opening a client are each things
|
||||
/// that can fail a run. Which tools a server exposes stays its entry's own `include_tools` /
|
||||
/// `exclude_tools`, the only place that choice is made.
|
||||
fn narrow_roster(tools: Vec<AgentTool>, enabled_tools: Option<&[String]>) -> Vec<AgentTool> {
|
||||
let Some(enabled) = enabled_tools else {
|
||||
return tools;
|
||||
};
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|t| tool_enabled_name(t).is_some_and(|name| enabled.iter().any(|n| n == name)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The log line for names in `enabled_tools` that name nothing on the agent, if any. The list can
|
||||
/// be computed per run, so a name that has since been renamed away must not fail the step — but it
|
||||
/// would otherwise silently narrow the agent, so the run says how many of its names matched nothing.
|
||||
///
|
||||
/// Counted, never quoted: a name is an argument value, and one written as `$var:path` reaches the
|
||||
/// worker already replaced by the variable's own value. Quoting it would write that value to the
|
||||
/// job log, where the masks a job registers never reach it — they are applied to a subprocess's
|
||||
/// output in `handle_child` and nowhere else, and `append_logs` stores what it is given verbatim.
|
||||
fn unmatched_enabled_tools_message(
|
||||
enabled_tools: &[String],
|
||||
advertised: &[&str],
|
||||
) -> Option<String> {
|
||||
let unmatched = enabled_tools
|
||||
.iter()
|
||||
.filter(|name| !advertised.contains(&name.as_str()))
|
||||
.count();
|
||||
if unmatched == 0 {
|
||||
return None;
|
||||
}
|
||||
let subject = if unmatched == 1 { "name" } else { "names" };
|
||||
Some(format!(
|
||||
"--- ENABLED TOOLS: {unmatched} {subject} named no tool of this agent and had no effect ---\n"
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn handle_ai_agent_job(
|
||||
// connection
|
||||
conn: &Connection,
|
||||
@@ -378,9 +460,10 @@ pub async fn handle_ai_agent_job(
|
||||
));
|
||||
};
|
||||
|
||||
// A linked step takes its brain and tools from the resource and keeps only the flow-local
|
||||
// inputs (user_message/user_attachments) of its own; both stay rigid, so the one thing it may
|
||||
// bind to this flow is the tools' inputs, overlaid from `tool_inputs` below.
|
||||
// A linked step takes its brain and tools from the resource and keeps only its own flow-local
|
||||
// inputs. The brain and the roster stay rigid; what the step binds to this flow is the message
|
||||
// it asks, which of those tools this use may call, the conversation it is part of, and the
|
||||
// tools' own inputs — the last overlaid from `tool_inputs` below.
|
||||
let (args, tools): (AIAgentArgs, Vec<AgentTool>) = if let Some(agent_ref) = agent.as_deref() {
|
||||
let agent_path = agent_ref
|
||||
.trim_start_matches("$res:")
|
||||
@@ -434,7 +517,7 @@ pub async fn handle_ai_agent_job(
|
||||
// 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"] {
|
||||
for key in ["user_message", "user_attachments", "enabled_tools"] {
|
||||
if let Some(v) = local_args.get(key) {
|
||||
brain.insert(
|
||||
key.to_string(),
|
||||
@@ -477,6 +560,20 @@ 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();
|
||||
// Taken before the narrowing consumes the roster, and only by a run that narrows: they are what
|
||||
// its names are matched against, so they are also what tells it a name matched nothing.
|
||||
let roster_names: Vec<String> = match enabled_tools {
|
||||
Some(_) => tools
|
||||
.iter()
|
||||
.filter_map(|t| tool_enabled_name(t).map(str::to_string))
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let tools = 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
|
||||
@@ -541,12 +638,7 @@ pub async fn handle_ai_agent_job(
|
||||
let job = job;
|
||||
let user_description = tool_descriptions.get(&t.id).cloned();
|
||||
async move {
|
||||
let Some(summary) = t.summary.as_ref().filter(|s| TOOL_NAME_REGEX.is_match(s)) else {
|
||||
return Err(Error::internal_err(format!(
|
||||
"Invalid tool name: {:?}",
|
||||
t.summary
|
||||
)));
|
||||
};
|
||||
let summary = flow_module_tool_name(t.summary.as_deref())?;
|
||||
|
||||
// Extract schema, input_transforms, and an auto-derived description from the module value
|
||||
let module_value = t.get_value()?;
|
||||
@@ -657,7 +749,7 @@ pub async fn handle_ai_agent_job(
|
||||
def: ToolDef {
|
||||
r#type: "function".to_string(),
|
||||
function: ToolDefFunction {
|
||||
name: summary.clone(),
|
||||
name: summary.to_string(),
|
||||
description: Some(description),
|
||||
parameters: schema.unwrap_or_else(|| {
|
||||
to_raw_value(&serde_json::json!({
|
||||
@@ -687,6 +779,22 @@ pub async fn handle_ai_agent_job(
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
if let Some(enabled) = enabled_tools {
|
||||
let matchable: Vec<&str> = roster_names.iter().map(|s| s.as_str()).collect();
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"ai_agent",
|
||||
"dynamic_tools",
|
||||
if tools.is_empty() && !has_websearch {
|
||||
"no_tools"
|
||||
} else {
|
||||
"tools"
|
||||
},
|
||||
);
|
||||
if let Some(message) = unmatched_enabled_tools_message(enabled, &matchable) {
|
||||
append_logs(&job.id, &job.workspace_id, message, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
let mut inner_occupancy_metrics = occupancy_metrics.clone();
|
||||
|
||||
let stream_notifier = StreamNotifier::new(conn, job);
|
||||
@@ -1845,6 +1953,123 @@ mod tests {
|
||||
assert!(matches!(&tools[2].value, ToolValue::Mcp(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_roster_keeps_the_entries_a_run_named() {
|
||||
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![],
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn websearch(id: &str, summary: Option<&str>) -> AgentTool {
|
||||
AgentTool {
|
||||
id: id.to_string(),
|
||||
summary: summary.map(str::to_string),
|
||||
description: None,
|
||||
value: ToolValue::Websearch(windmill_common::flows::WebsearchToolValue {}),
|
||||
}
|
||||
}
|
||||
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()
|
||||
};
|
||||
let ids =
|
||||
|tools: &[AgentTool]| -> Vec<String> { tools.iter().map(|t| t.id.clone()).collect() };
|
||||
|
||||
// No list at all: the whole roster, as every agent written before the field expects.
|
||||
assert_eq!(
|
||||
names(&narrow_roster(roster(), None)),
|
||||
["get_user", "send_email", "github"]
|
||||
);
|
||||
|
||||
// An empty list is a list: nothing is advertised, and no server is resolved to find that
|
||||
// out — one that is down must not fail a run that switched it off.
|
||||
assert!(names(&narrow_roster(roster(), Some(&[]))).is_empty());
|
||||
|
||||
let enabled = ["get_user".to_string(), "renamed_away".to_string()];
|
||||
assert_eq!(
|
||||
names(&narrow_roster(roster(), Some(&enabled))),
|
||||
["get_user"]
|
||||
);
|
||||
// Counted, not quoted: a name is an argument value, and one holding `$var:` arrives as the
|
||||
// variable's own value, which this log is not masked for.
|
||||
assert_eq!(
|
||||
unmatched_enabled_tools_message(&enabled, &["get_user", "send_email", "u/test/gh"])
|
||||
.unwrap(),
|
||||
"--- ENABLED TOOLS: 1 name named no tool of this agent and had no effect ---\n"
|
||||
);
|
||||
|
||||
// A server is named by the resource it points at, bare, and never by its summary: that
|
||||
// label is shown to nobody and two entries may carry the same one.
|
||||
let named_server = ["u/test/gh".to_string()];
|
||||
assert_eq!(
|
||||
names(&narrow_roster(roster(), Some(&named_server))),
|
||||
["github"]
|
||||
);
|
||||
assert!(unmatched_enabled_tools_message(&named_server, &["u/test/gh"]).is_none());
|
||||
// Alongside a name that does match, so the summary being rejected is what empties it.
|
||||
let summary_and_tool = ["get_user".to_string(), "github".to_string()];
|
||||
assert_eq!(
|
||||
names(&narrow_roster(roster(), Some(&summary_and_tool))),
|
||||
["get_user"]
|
||||
);
|
||||
assert!(narrow_roster(roster(), Some(&["u/test/other".to_string()])).is_empty());
|
||||
|
||||
// Web search reaches the model as a provider capability rather than a tool, so it has no
|
||||
// name of its own and is enabled by a reserved one, whatever label it was authored with.
|
||||
for label in [None, Some("Web Search")] {
|
||||
let mut with_websearch = roster();
|
||||
with_websearch.push(websearch("w", label));
|
||||
assert_eq!(
|
||||
ids(&narrow_roster(
|
||||
with_websearch,
|
||||
Some(&[WEBSEARCH_ENABLED_NAME.to_string()])
|
||||
)),
|
||||
["w"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_cannot_take_the_name_web_search_is_enabled_by() {
|
||||
// Nothing else in a roster may answer to the reserved name, or enabling that tool would
|
||||
// switch web search on beside it. Held here rather than by the shape of the name, which is
|
||||
// an ordinary identifier: the run refuses to start instead.
|
||||
assert!(TOOL_NAME_REGEX.is_match(WEBSEARCH_ENABLED_NAME));
|
||||
assert!(flow_module_tool_name(Some(WEBSEARCH_ENABLED_NAME)).is_err());
|
||||
|
||||
assert_eq!(flow_module_tool_name(Some("get_user")).unwrap(), "get_user");
|
||||
assert!(flow_module_tool_name(Some("get user")).is_err());
|
||||
assert!(flow_module_tool_name(None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_description_prefers_explicit_over_derived_and_name() {
|
||||
assert_eq!(
|
||||
|
||||
Generated
+1
-1
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
|
||||
without any identifying data leaving the instance.
|
||||
|
||||
It currently carries 50 registered actions across nineteen features (`ai_session`, `ai_chat`,
|
||||
It currently carries 51 registered actions across nineteen features (`ai_session`, `ai_chat`,
|
||||
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`,
|
||||
`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`,
|
||||
`usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the
|
||||
|
||||
@@ -16,8 +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`) 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`, `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: an absent field carries every tool, a list carries the ones it names,
|
||||
and an empty list carries none.
|
||||
- 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
|
||||
@@ -45,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
|
||||
|
||||
@@ -1080,14 +1080,15 @@
|
||||
>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, which skin approval steps are given, 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, the name of any public hub project imported from the home page and how far
|
||||
that import got, and whether a pre-approved trial offer was opened, 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, which skin approval steps are given, 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, the name of any public hub project imported from the home page and how
|
||||
far that import got, and whether a pre-approved trial offer was opened, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
@@ -1144,14 +1145,15 @@
|
||||
>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, which skin approval steps are given, 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, the name of any public hub project imported from the home page and how far
|
||||
that import got, and whether a pre-approved trial offer was opened, 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, which skin approval steps are given, 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, the name of any public hub project imported from the home page and how
|
||||
far that import got, and whether a pre-approved trial offer was opened, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -37,6 +37,9 @@ describe('agentFieldIsSet', () => {
|
||||
|
||||
it('reads anything the user authored as set', () => {
|
||||
expect(set('temperature', { type: 'static', value: 0 })).toBe(true)
|
||||
// An empty `enabled_tools` advertises no tools, so it is a choice rather than an unset field:
|
||||
// giving the spec an `implicit` of `[]` would hide the row while the run still carries none.
|
||||
expect(set('enabled_tools', { type: 'static', value: [] })).toBe(true)
|
||||
expect(set('output_type', { type: 'static', value: 'image' })).toBe(true)
|
||||
expect(set('memory', { type: 'static', value: { kind: 'auto', context_length: 5 } })).toBe(true)
|
||||
expect(set('max_iterations', { type: 'javascript', expr: 'flow_input.loops' })).toBe(true)
|
||||
|
||||
@@ -40,14 +40,16 @@ export interface AgentFieldSpec {
|
||||
* before. Also what the add menu seeds the field with, so a new row opens showing what it
|
||||
* overrides. */
|
||||
implicit?: unknown
|
||||
/** The same value written for a reader, shown under the field's name in the add menu. */
|
||||
/** What the add menu opens the field on, where that is not `implicit`. Only a field whose empty
|
||||
* value is a choice of its own needs one: an empty `enabled_tools` advertises no tools, so its
|
||||
* row opens on an empty list to keep what is shown and what a run does the same thing, which
|
||||
* leaves an absent field as the only way to say every tool. */
|
||||
seed?: unknown
|
||||
/** What leaving the field unset does, written for a reader, shown under the field's name in the
|
||||
* add menu. */
|
||||
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 +107,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,
|
||||
@@ -115,6 +116,15 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
core: true,
|
||||
virtual: true
|
||||
},
|
||||
{
|
||||
key: 'enabled_tools',
|
||||
group: 'tools',
|
||||
label: 'Enabled tools',
|
||||
tooltip:
|
||||
'Which of the agent tools a run carries, so it costs no more than it needs. Selecting none leaves the agent with no tools, and unsetting the field gives it all of them. Set it to an expression to decide per run, naming each one the way this list does: a tool by its own name, an MCP server by its resource path, and web search by "__wm_web_search". An MCP server carries every tool it exposes, which its own include and exclude lists decide.',
|
||||
seed: [],
|
||||
defaultHint: 'Default: all of them'
|
||||
},
|
||||
{
|
||||
key: 'max_iterations',
|
||||
group: 'tools',
|
||||
@@ -156,6 +166,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.
|
||||
|
||||
@@ -143,16 +143,20 @@ 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: [] },
|
||||
// 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: [] }
|
||||
user_attachments: { type: 'static', value: [] },
|
||||
enabled_tools: { type: 'javascript', expr: 'flow_input.tools' }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ 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 inputs below are
|
||||
// intentionally excluded — they are supplied per-flow.
|
||||
export const AGENT_BRAIN_KEYS = [
|
||||
'provider',
|
||||
'output_type',
|
||||
@@ -16,7 +16,13 @@ export const AGENT_BRAIN_KEYS = [
|
||||
'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.
|
||||
*
|
||||
* `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', 'enabled_tools'] as const
|
||||
|
||||
export type AgentTool = Record<string, any>
|
||||
|
||||
@@ -116,7 +122,7 @@ export function inputTransformsToAgentConfig(
|
||||
|
||||
/**
|
||||
* Reduce the AI agent schema to only the flow-local inputs. Used when a step is linked to a saved
|
||||
* agent: the brain fields come from the resource, so only user_message/user_attachments stay editable.
|
||||
* agent: the brain fields come from the resource, so only `AGENT_FLOW_LOCAL_KEYS` stay editable.
|
||||
*/
|
||||
export function flowLocalAgentSchema(schema: any): any {
|
||||
if (!schema?.properties) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// `agentToolUtils` reaches the copilot bundle, and Monaco's CSS with it, through this one import.
|
||||
// Only `createAiAgentTool` reads it, and nothing below does.
|
||||
vi.mock('../aiProviderStorage', () => ({ loadStoredConfig: () => undefined }))
|
||||
|
||||
import { getToolNameError, toolEnabledName, WEBSEARCH_ENABLED_NAME } from './agentToolUtils'
|
||||
|
||||
/**
|
||||
* The names this returns are the ones `enabled_tools` holds and `tool_enabled_name` in
|
||||
* `ai_executor.rs` matches against, so the two have to agree: a name only one side produces
|
||||
* silently drops the tool from every run that narrows.
|
||||
*/
|
||||
describe('toolEnabledName', () => {
|
||||
it('names a flow module tool by the name the model is shown', () => {
|
||||
expect(
|
||||
toolEnabledName({ id: 'a', summary: 'get_user', value: { tool_type: 'flowmodule' } } as any)
|
||||
).toBe('get_user')
|
||||
})
|
||||
|
||||
it('names an MCP server by its bare path, never the summary two servers may share', () => {
|
||||
// `$res:` and all is how the roster stores it, but a name carrying that prefix is resolved to
|
||||
// the resource itself before the worker sees it, so the list can only hold the bare path.
|
||||
expect(
|
||||
toolEnabledName({
|
||||
id: 'm',
|
||||
summary: 'github',
|
||||
value: { tool_type: 'mcp', resource_path: '$res:u/admin/gh' }
|
||||
} as any)
|
||||
).toBe('u/admin/gh')
|
||||
})
|
||||
|
||||
it('names web search by a reserved name, whatever label it carries', () => {
|
||||
// It reaches the model as a provider capability rather than a tool, so the editor's label is
|
||||
// not a name: something else in the roster could carry it and be switched on with it.
|
||||
expect(toolEnabledName({ id: 'w', value: { tool_type: 'websearch' } } as any)).toBe(
|
||||
WEBSEARCH_ENABLED_NAME
|
||||
)
|
||||
expect(
|
||||
toolEnabledName({ id: 'w', summary: 'Web Search', value: { tool_type: 'websearch' } } as any)
|
||||
).toBe(WEBSEARCH_ENABLED_NAME)
|
||||
})
|
||||
|
||||
it('reserves that name against every other kind', () => {
|
||||
// A flow module tool cannot be called it, so enabling a tool never enables web search beside
|
||||
// it. Nothing about the name itself stops that — it is an ordinary identifier — so the rule
|
||||
// is `getToolNameError` refusing it, as `flow_module_tool_name` does on the worker.
|
||||
expect(getToolNameError(WEBSEARCH_ENABLED_NAME)).toBe(
|
||||
`'${WEBSEARCH_ENABLED_NAME}' is a reserved name`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,12 @@ import { loadStoredConfig } from '../aiProviderStorage'
|
||||
import { AI_AGENT_SCHEMA } from './flowInfers'
|
||||
import { forbiddenIds } from './idUtils'
|
||||
|
||||
/** What every websearch entry is named by, mirroring `WEBSEARCH_ENABLED_NAME` in `ai_executor.rs`.
|
||||
* Reserved rather than merely conventional: `getToolNameError` refuses it to a flow module tool,
|
||||
* as the worker does, or that tool would answer to the same name and be switched on with web
|
||||
* search. */
|
||||
export const WEBSEARCH_ENABLED_NAME = '__wm_web_search'
|
||||
|
||||
/**
|
||||
* A tool's `summary` is the name the LLM sees, and the worker rejects any name that does not match
|
||||
* `^[a-zA-Z0-9_]+$` (`ai_executor.rs`), so an unvalidated name fails on every run of the flow.
|
||||
@@ -29,7 +35,7 @@ export function getToolNameError(
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
|
||||
return 'Tool name must only contain letters, numbers and underscores'
|
||||
}
|
||||
if (forbiddenIds.includes(name)) {
|
||||
if (forbiddenIds.includes(name) || name === WEBSEARCH_ENABLED_NAME) {
|
||||
return `'${name}' is a reserved name`
|
||||
}
|
||||
if (siblingNames && siblingNames.filter((n) => n === name).length > 1) {
|
||||
@@ -99,6 +105,26 @@ export function toolDisplayName(tool: AgentTool): string | undefined {
|
||||
return tool?.summary || value?.path || value?.resource_path || undefined
|
||||
}
|
||||
|
||||
/** The name `enabled_tools` holds a tool by: the name the model is shown, except for an entry the
|
||||
* model is shown nothing of, which is named by whatever identifies it instead. An MCP server is
|
||||
* named by the resource it points at, and web search by `WEBSEARCH_ENABLED_NAME`, since either
|
||||
* summary is a label something else may share and naming one would enable both.
|
||||
*
|
||||
* The MCP path is offered bare. It is stored with the `$res:` it was authored with, and an
|
||||
* `enabled_tools` entry carrying that prefix is resolved to the resource's own value before the
|
||||
* step runs, reaching the worker as an object where a name is expected. Mirrors
|
||||
* `tool_enabled_name` in `ai_executor.rs`. */
|
||||
export function toolEnabledName(tool: AgentTool): string | undefined {
|
||||
const value = tool?.value as Record<string, any>
|
||||
if (value?.tool_type === 'mcp') {
|
||||
return (value?.resource_path as string | undefined)?.replace(/^\$res:/, '') || undefined
|
||||
}
|
||||
if (value?.tool_type === 'websearch') {
|
||||
return WEBSEARCH_ENABLED_NAME
|
||||
}
|
||||
return toolDisplayName(tool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AI Agent tool (nested agent)
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,10 +15,20 @@
|
||||
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">
|
||||
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'
|
||||
@@ -33,10 +43,10 @@
|
||||
import type VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
|
||||
import { Plus, X } from 'lucide-svelte'
|
||||
import { AlertTriangle, Plus, X } from 'lucide-svelte'
|
||||
import type { PickableProperties } from '../previousResults'
|
||||
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
|
||||
import type { AgentTool } from '../agentToolUtils'
|
||||
import { toolEnabledName, type AgentTool } from '../agentToolUtils'
|
||||
import {
|
||||
AGENT_FIELDS,
|
||||
AGENT_FIELD_GROUPS,
|
||||
@@ -153,6 +163,26 @@
|
||||
|
||||
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(() => {
|
||||
// By what each tool is named, not the summary alone: an MCP entry is added without one and is
|
||||
// named by its resource path, so keying on `summary` would leave a whole server with no name
|
||||
// to pick. `narrow_roster` matches that path for the same reason.
|
||||
const names = tools
|
||||
.map((tool) => toolEnabledName(tool))
|
||||
.filter((name): name is string => !!name)
|
||||
const properties = schemaProperties
|
||||
untrack(() => {
|
||||
const list = properties['enabled_tools']
|
||||
if (list && !deepEqual(list.items?.enum, names)) {
|
||||
list.items = { ...(list.items ?? { type: 'string' }), enum: names }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let scopedFields = $derived(
|
||||
AGENT_FIELDS.filter(
|
||||
(spec) =>
|
||||
@@ -219,9 +249,10 @@
|
||||
|
||||
function addField(spec: AgentFieldSpec) {
|
||||
// `flowInfers` re-seeds every key on load, so adding cannot mean creating the key: it means
|
||||
// showing the row, seeded at what a run does today so the field opens on what it overrides.
|
||||
// showing the row. Seeded at what a run does today, so the field opens on what it overrides,
|
||||
// except where an empty value is a choice of its own rather than the absent one (`seed`).
|
||||
if (args) {
|
||||
args[spec.key] = { type: 'static', value: structuredClone(spec.implicit) }
|
||||
args[spec.key] = { type: 'static', value: structuredClone(spec.seed ?? spec.implicit) }
|
||||
}
|
||||
visible.add(spec.key)
|
||||
}
|
||||
@@ -239,6 +270,17 @@
|
||||
delete inputCheck[spec.key]
|
||||
}
|
||||
|
||||
// Holding `enabled_tools` and naming nothing advertises no tools at all. That is a choice the
|
||||
// field has to allow, and the one the row opens on, so it says so where it is made rather than
|
||||
// leaving it to be discovered in a run. Only a static list can be read here: an expression's
|
||||
// value exists only once the run it decides is under way.
|
||||
let noToolsEnabled = $derived.by(() => {
|
||||
const transform = args?.['enabled_tools']
|
||||
return (
|
||||
transform?.type === 'static' && Array.isArray(transform.value) && transform.value.length === 0
|
||||
)
|
||||
})
|
||||
|
||||
let emptyArgNames = $derived(
|
||||
[...visible].filter((key) => {
|
||||
if (!(key in schemaProperties)) return false
|
||||
@@ -377,6 +419,14 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
</InputTransformForm>
|
||||
{#if spec.key === 'enabled_tools' && noToolsEnabled}
|
||||
<div
|
||||
class="mt-1 flex items-center gap-1 text-2xs text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
<AlertTriangle size={12} />
|
||||
Nothing selected: the agent runs with no tools.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
|
||||
@@ -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,8 +1234,13 @@
|
||||
helperScript={retrieveDynCodeAndLang(flowModule.value)}
|
||||
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
|
||||
workspace={opWs}
|
||||
visibilityKey={`${$pathStore}:${linkedToolsModuleId}`}
|
||||
tools={flowModule.value.tools ?? []}
|
||||
visibilityKey={agentFieldsKey}
|
||||
tools={agentLinked
|
||||
? getLinkedAgentTools(
|
||||
linkedToolsScope(opWs, $pathStore),
|
||||
linkedToolsModuleId
|
||||
)
|
||||
: (flowModule.value.tools ?? [])}
|
||||
onSelectTool={noToolNavigation
|
||||
? undefined
|
||||
: (toolId) => selectionManager.selectId(toolId, { openPanel: true })}
|
||||
@@ -1336,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,6 +151,20 @@ 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`). Absence, not an empty list, is what carries every
|
||||
// tool: a step that holds the field and names nothing has chosen to advertise none.
|
||||
// Shown for image output as the roster it narrows is, even though neither is used there.
|
||||
enabled_tools: {
|
||||
type: 'array',
|
||||
// Deliberately short. It is the only place the field's text is always on screen rather than
|
||||
// behind the row's tooltip, and the surface it shows on is the run form, which offers the
|
||||
// names in a picker and has no unset state to explain.
|
||||
description: 'Which of the agent tools a run may call.',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
max_completion_tokens: {
|
||||
type: 'number',
|
||||
description: 'The most tokens the answer may use.'
|
||||
@@ -178,6 +192,7 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
'memory',
|
||||
'output_schema',
|
||||
'user_attachments',
|
||||
'enabled_tools',
|
||||
'max_completion_tokens',
|
||||
'temperature',
|
||||
'max_iterations'
|
||||
@@ -291,7 +306,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -1068,6 +1068,18 @@ 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 which of the tools configured in `tools` the agent may call
|
||||
this run. Leaving it unset carries every one of them; an empty array carries none.
|
||||
A tool is named as the model is shown it. An entry the model is shown nothing of is
|
||||
named by what identifies it instead: an MCP server by its resource path, carrying
|
||||
every tool it exposes (which of them stays that entry's include_tools/exclude_tools),
|
||||
and a websearch entry by the reserved name '__wm_web_search', whatever summary it carries
|
||||
(no tool may take that name).
|
||||
Example: ['get_user', 'u/admin/github_mcp', '__wm_web_search']
|
||||
max_completion_tokens:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
@@ -1115,7 +1127,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).
|
||||
(user_message/user_attachments/enabled_tools).
|
||||
tool_inputs:
|
||||
type: object
|
||||
description: |
|
||||
|
||||
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
Reference in New Issue
Block a user