feat(aiagent): handle ai agent as tool (#8031)

* worker: support AIAgent tools in AI executor

* worker: complete nested AIAgent tool execution path

* worker: inline AIAgent tool schema usage

* fix agent action

* frontend: add AI Agent as tool type in flow builder

Add the ability to insert a nested AI Agent as a tool within another
AI Agent step. Includes type definitions, factory function, graph icon,
insert/event wiring, and a dedicated editor component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove AiAgentToolEditor, reuse FlowModuleComponent for AI agent tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: populate all input transforms for nested AI agent tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: avoid missing v2_job_status error for nested AI agent tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* sqlx

* nit

* refactor: cleanup nested AI agent tool implementation

- Add max nesting depth guard (5) on parent chain traversal
- Reject 3+ level nesting explicitly with clear error message
- Remove unnecessary flow_step_id tuple scaffolding in tool dispatch
- Consolidate get_value() calls using borrow in first match
- Replace unsafe `as unknown as FlowModule` casts with agentToolToFlowModule()
- Simplify toolKind ternary chain with .includes() lookup
- Fix leftover over-indentation from tuple removal
- Remove duplicate doc comment on is_completed_input_transform

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: pass flow_step_id and flow_job_id overrides to run_agent for nested AI agents

For nested AI agent tools, job.flow_step_id is None and job.parent_job
points to the parent agent instead of the flow. This caused memory
read/write and flow context resolution to silently fail.

handle_ai_agent_job already computes the correct flow_step_id (via
runnable_path fallback) and flow_job_id (via parent chain traversal).
This change threads those values through run_agent and
ToolExecutionContext so all downstream consumers use the correct IDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* cleaning

* cleaning

* move const

* fix

* refactor: replace defaultToAi boolean with allowedAiTransforms whitelist

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: propagate root_job at push time, remove flow_job_id_override

Instead of threading flow_job_id_override through run_agent and
get_flow_context, propagate root_job and flow_innermost_root_job
when pushing tool jobs so nested AI agents can find the flow
job naturally via the existing job fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: simplify nested AI agent parent chain walk-up

Replace the generic depth-limited loop with a single-level check since
only flow → agent → nested agent tool is supported. Remove
MAX_AGENT_NESTING_DEPTH constant and flatten the module lookup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: reject 3+ level nested AI agent tools before job creation

Check at the parent agent level whether a nested AIAgent tool contains
AIAgent sub-tools. If so, return a fatal error immediately, preventing
the sub-job from being created and avoiding retry loops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve deadlock in nested AI agent tool execution

Replace channel forwarding with inline DB writes for tool job
completions. Nested agents used bounded(1) channels where a sub-tool's
forwarded result would fill the parent channel, leaving no room for the
agent's own completion — causing a deadlock. Writing directly via
add_completed_job/add_completed_job_error bypasses the channel entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-02-23 16:59:39 +01:00
committed by GitHub
parent 65aeb3edc5
commit cd4d6f9414
34 changed files with 446 additions and 185 deletions
@@ -43,8 +43,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -42,8 +42,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -38,8 +38,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -77,8 +77,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\" FROM v2_job WHERE id = $1",
"query": "SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\", parent_job, flow_step_id FROM v2_job WHERE id = $1",
"describe": {
"columns": [
{
@@ -42,12 +42,21 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 3,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "flow_step_id",
"type_info": "Varchar"
}
],
"parameters": {
@@ -58,8 +67,10 @@
"nullable": [
true,
true,
false
false,
true,
true
]
},
"hash": "805d633de90fee335f1726284eda0dbc200d45960fb8dea867492c8c7dd096d5"
"hash": "7aaa5b0bd873c2029e2201d287ea0aaae04678ac105374bbe387e534a6cb6333"
}
@@ -44,8 +44,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -102,8 +102,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -32,8 +32,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -72,8 +72,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -77,8 +77,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -102,8 +102,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -72,8 +72,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -41,8 +41,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -41,8 +41,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -31,8 +31,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -37,8 +37,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -77,8 +77,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
@@ -32,8 +32,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}
+44 -46
View File
@@ -540,53 +540,48 @@ impl FlowModule {
) -> anyhow::Result<()> {
for module in modules {
cb(module)?;
match module
let module_value = module
.get_value()
.map_err(|e| anyhow::anyhow!("Module '{}': {}", module.id, e))?
{
FlowModuleValue::ForloopFlow { modules, .. }
| FlowModuleValue::WhileloopFlow { modules, .. } => {
Self::traverse_modules(&modules, cb)?;
}
FlowModuleValue::BranchOne { branches, default, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
Self::traverse_modules(&default, cb)?;
}
FlowModuleValue::BranchAll { branches, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
}
FlowModuleValue::AIAgent { tools, .. } => {
for tool in tools {
match &tool.value {
ToolValue::FlowModule(module_value) => match module_value {
FlowModuleValue::ForloopFlow { modules, .. }
| FlowModuleValue::WhileloopFlow { modules, .. } => {
Self::traverse_modules(&modules, cb)?;
}
FlowModuleValue::BranchOne { branches, default, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
Self::traverse_modules(&default, cb)?;
}
FlowModuleValue::BranchAll { branches, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
}
_ => {}
},
ToolValue::Mcp(_) => {}
ToolValue::Websearch(_) => {}
}
}
}
_ => {}
.map_err(|e| anyhow::anyhow!("Module '{}': {}", module.id, e))?;
Self::traverse_module_value(&module_value, cb)?;
}
Ok(())
}
fn traverse_module_value<C: FnMut(&FlowModule) -> anyhow::Result<()>>(
module_value: &FlowModuleValue,
cb: &mut C,
) -> anyhow::Result<()> {
match module_value {
FlowModuleValue::ForloopFlow { modules, .. }
| FlowModuleValue::WhileloopFlow { modules, .. } => {
Self::traverse_modules(modules, cb)?;
}
FlowModuleValue::BranchOne { branches, default, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
Self::traverse_modules(default, cb)?;
}
FlowModuleValue::BranchAll { branches, .. } => {
for branch in branches {
Self::traverse_modules(&branch.modules, cb)?;
}
}
FlowModuleValue::AIAgent { tools, .. } => {
for tool in tools {
let Some(tool_module) = Option::<FlowModule>::from(tool) else {
continue;
};
cb(&tool_module)?;
let tool_value = tool_module
.get_value()
.map_err(|e| anyhow::anyhow!("Tool module '{}': {}", tool_module.id, e))?;
Self::traverse_module_value(&tool_value, cb)?;
}
}
_ => {}
}
Ok(())
}
@@ -1071,7 +1066,10 @@ impl Into<Box<RawValue>> for FlowModuleValue {
}
}
pub fn ordered_map<S>(value: &HashMap<String, InputTransform>, serializer: S) -> Result<S::Ok, S::Error>
pub fn ordered_map<S>(
value: &HashMap<String, InputTransform>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
+96 -50
View File
@@ -3,8 +3,8 @@ use crate::ai::types::McpToolSource;
use crate::ai::types::*;
use crate::ai::utils::{
add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow,
update_flow_status_module_with_actions, update_flow_status_module_with_actions_success,
FlowContext,
is_completed_input_transform, update_flow_status_module_with_actions,
update_flow_status_module_with_actions_success, FlowContext,
};
use crate::common::OccupancyMetrics;
use crate::result_processor::handle_non_flow_job_error;
@@ -21,7 +21,6 @@ use serde_json::value::RawValue;
use std::{collections::HashMap, sync::Arc};
use uuid::Uuid;
use windmill_common::ai_types::OpenAIToolCall;
use windmill_common::flows::InputTransform;
use windmill_common::jobs::JobPayload;
#[cfg(feature = "mcp")]
@@ -35,15 +34,15 @@ type McpClient = McpClientStub;
use windmill_common::{
client::AuthedClient,
db::DB,
error::{to_anyhow, Error},
error::Error,
flow_conversations::MessageType,
flow_status::AgentAction,
flows::FlowModuleValue,
worker::{to_raw_value, Connection},
};
use windmill_queue::{
get_mini_pulled_job, push, JobCompleted, MiniCompletedJob, MiniPulledJob, PushArgs,
PushIsolationLevel,
add_completed_job, add_completed_job_error, get_mini_pulled_job, push, MiniCompletedJob,
MiniPulledJob, PushArgs, PushIsolationLevel,
};
/// Context for tool execution containing all required references and state
@@ -54,8 +53,9 @@ pub struct ToolExecutionContext<'a> {
// Job context
pub job: &'a MiniPulledJob,
pub parent_job: &'a Uuid,
pub parent_job: Option<&'a Uuid>,
pub summary: &'a Option<&'a str>,
pub flow_step_id_override: Option<&'a str>,
// Execution parameters
pub client: &'a AuthedClient,
@@ -66,7 +66,6 @@ pub struct ToolExecutionContext<'a> {
// Runtime state
pub occupancy_metrics: &'a mut OccupancyMetrics,
pub job_completed_tx: &'a JobCompletedSender,
pub killpill_rx: &'a mut tokio::sync::broadcast::Receiver<()>,
// Optional streaming & chat
@@ -283,7 +282,9 @@ async fn execute_windmill_tool(
module_id: tool_module.id.clone(),
});
update_flow_status_module_with_actions(ctx.db, ctx.parent_job, actions).await?;
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions(ctx.db, parent_job, actions).await?;
}
let raw_tool_call_args = if tool_call.function.arguments.is_empty() {
"{}".to_string()
@@ -301,11 +302,14 @@ async fn execute_windmill_tool(
)
})?;
let tool_value = tool_module.get_value()?;
// Get input transforms given by the user and merge them with AI given args
let input_transforms = match tool_module.get_value()? {
FlowModuleValue::Script { input_transforms, .. } => input_transforms,
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
let input_transforms = match &tool_value {
FlowModuleValue::Script { input_transforms, .. }
| FlowModuleValue::RawScript { input_transforms, .. }
| FlowModuleValue::FlowScript { input_transforms, .. }
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
_ => {
return Err(Error::internal_err(format!(
"Unsupported tool: {}",
@@ -331,17 +335,8 @@ async fn execute_windmill_tool(
// Evaluate each input transform and merge with AI-provided args
for (key, transform) in input_transforms.iter() {
// We skip static empty / null values, those are the one the AI will fill in
match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
if val.is_empty() || val == "null" {
continue;
}
}
InputTransform::Ai => {
continue;
}
_ => (),
if !is_completed_input_transform(transform) {
continue;
}
let result = evaluate_input_transform::<Box<RawValue>>(
transform,
@@ -356,7 +351,7 @@ async fn execute_windmill_tool(
tool_call_args.insert(key.clone(), result);
}
let job_payload = match tool_module.get_value()? {
let job_payload = match tool_value {
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
script_to_payload(
script_hash,
@@ -380,7 +375,6 @@ async fn execute_windmill_tool(
} => {
let path = path
.unwrap_or_else(|| format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id));
raw_script_to_payload(
path,
content,
@@ -394,8 +388,7 @@ async fn execute_windmill_tool(
}
FlowModuleValue::FlowScript { id, language, concurrency_settings, tag, .. } => {
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
let payload = JobPayloadWithTag {
JobPayloadWithTag {
payload: JobPayload::FlowScript {
id,
language,
@@ -409,8 +402,29 @@ async fn execute_windmill_tool(
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
timeout: None,
on_behalf_of: None,
};
payload
}
}
FlowModuleValue::AIAgent { tools: sub_tools, .. } => {
let has_nested_agent_tools = sub_tools.iter().any(|t| {
matches!(
t.value,
windmill_common::flows::ToolValue::FlowModule(FlowModuleValue::AIAgent { .. })
)
});
if has_nested_agent_tools {
return Err(Error::internal_err(
"AI agent tools cannot be nested beyond 2 levels. The nested agent tool contains \
AIAgent sub-tools, which would exceed the maximum nesting depth.".to_string()
));
}
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
JobPayloadWithTag {
payload: JobPayload::AIAgent { path },
tag: None,
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
timeout: None,
on_behalf_of: None,
}
}
_ => {
return Err(Error::internal_err(format!(
@@ -452,8 +466,8 @@ async fn execute_windmill_tool(
None,
ctx.job.schedule_path(),
Some(ctx.job.id),
None,
None,
ctx.job.root_job.or(Some(ctx.job.id)),
ctx.job.flow_innermost_root_job.or(Some(ctx.job.id)),
Some(job_id),
false,
false,
@@ -544,7 +558,6 @@ async fn execute_windmill_tool(
ctx.occupancy_metrics.total_duration_of_running_jobs =
updated_occupancy.total_duration_of_running_jobs;
// Continue with match on handle_result
match handle_result {
Err(err) => {
handle_tool_execution_error(
@@ -627,7 +640,9 @@ async fn handle_tool_execution_error(
.await?;
}
update_flow_status_module_with_actions_success(ctx.db, ctx.parent_job, false).await?;
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
}
// Add tool message to conversation if chat_input_enabled (error case)
add_tool_message_to_chat(ctx, Some(job_id), &error_message, false).await;
@@ -649,23 +664,50 @@ async fn handle_tool_execution_success(
let send_result = inner_job_completed_rx.bounded_rx.try_recv().ok();
let result = if let Some(SendResult {
result: SendResultPayload::JobCompleted(JobCompleted { result, .. }),
..
}) = send_result.as_ref()
result: SendResultPayload::JobCompleted(ref jc), ..
}) = send_result
{
let result = result.clone();
ctx.job_completed_tx
.send(send_result.unwrap().result, true)
let result = jc.result.clone();
// Write tool completion to the DB inline instead of forwarding through
// the parent channel. Forwarding would deadlock for nested agents: the
// sub-tool result would fill the parent's bounded(1) channel, leaving
// no room for the agent's own completion from process_result.
if jc.success {
add_completed_job(
ctx.db,
&jc.job,
true,
false,
sqlx::types::Json(&*jc.result),
jc.result_columns.clone(),
jc.mem_peak,
jc.canceled_by.clone(),
false,
jc.duration,
jc.from_cache.unwrap_or(false),
)
.await
.map_err(to_anyhow)?;
.map_err(|e| Error::internal_err(format!("Failed to add completed job: {e}")))?;
} else {
let error_value: serde_json::Value =
serde_json::from_str(jc.result.get()).unwrap_or_else(|_| {
serde_json::json!({ "message": format!("Non serializable error: {}", jc.result.get()) })
});
add_completed_job_error(
ctx.db,
&jc.job,
jc.mem_peak,
jc.canceled_by.clone(),
error_value,
ctx.worker_name,
false,
jc.duration,
)
.await
.map_err(|e| Error::internal_err(format!("Failed to add completed job error: {e}")))?;
}
result
} else {
if let Some(send_result) = send_result {
ctx.job_completed_tx
.send(send_result.result, true)
.await
.map_err(to_anyhow)?;
}
return Err(Error::internal_err(
"Tool job completed but no result".to_string(),
));
@@ -696,7 +738,9 @@ async fn handle_tool_execution_success(
.await?;
}
update_flow_status_module_with_actions_success(ctx.db, ctx.parent_job, success).await?;
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?;
}
// Add tool message to conversation if chat_input_enabled
let content = if success {
@@ -731,8 +775,10 @@ async fn add_tool_message_to_chat(
.and_then(|fs| fs.memory_id)
{
let db_clone = ctx.db.clone();
let step_name =
get_step_name_from_flow(ctx.summary.as_deref(), ctx.job.flow_step_id.as_deref());
let effective_step_id = ctx
.flow_step_id_override
.or(ctx.job.flow_step_id.as_deref());
let step_name = get_step_name_from_flow(ctx.summary.as_deref(), effective_step_id);
let content = content.to_string();
// Spawn task because we do not need to wait for the result
+17 -9
View File
@@ -62,6 +62,17 @@ pub fn parse_raw_script_schema(
Ok(to_raw_value(&schema))
}
pub fn is_completed_input_transform(transform: &InputTransform) -> bool {
match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
!val.is_empty() && val != "null"
}
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
InputTransform::Ai => false,
}
}
/// Filters out properties from a JSON schema that have completed input transforms.
/// This allows AI agents to only see and fill parameters that don't have user-configured values.
pub fn filter_schema_by_input_transforms(
@@ -77,14 +88,7 @@ pub fn filter_schema_by_input_transforms(
let keys_to_remove: HashSet<String> = input_transforms
.iter()
.filter_map(|(key, transform)| {
let is_completed = match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
!val.is_empty() && val != "null"
}
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
InputTransform::Ai => false,
};
let is_completed = is_completed_input_transform(transform);
if is_completed {
Some(key.clone())
} else {
@@ -123,10 +127,13 @@ pub fn filter_schema_by_input_transforms(
Ok(to_raw_value(&schema_value))
}
#[derive(Clone)]
pub struct FlowJobRunnableIdAndRawFlow {
pub runnable_id: Option<ScriptHash>,
pub raw_flow: Option<sqlx::types::Json<Box<RawValue>>>,
pub kind: JobKind,
pub parent_job: Option<Uuid>,
pub flow_step_id: Option<String>,
}
pub async fn get_flow_job_runnable_and_raw_flow(
@@ -135,7 +142,7 @@ pub async fn get_flow_job_runnable_and_raw_flow(
) -> windmill_common::error::Result<FlowJobRunnableIdAndRawFlow> {
let job = sqlx::query_as!(
FlowJobRunnableIdAndRawFlow,
"SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\" FROM v2_job WHERE id = $1",
"SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\", parent_job, flow_step_id FROM v2_job WHERE id = $1",
job_id
)
.fetch_one(db)
@@ -690,6 +697,7 @@ pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
FlowModuleValue::Script { input_transforms, .. } => input_transforms,
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
_ => return false,
};
+134 -25
View File
@@ -47,7 +47,6 @@ use crate::{
},
common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier},
handle_child::run_future_with_polling_update_job_poller,
JobCompletedSender,
};
lazy_static::lazy_static! {
@@ -79,11 +78,57 @@ lazy_static::lazy_static! {
})
.unwrap_or_default()
};
static ref AI_AGENT_TOOL_SCHEMA: Box<RawValue> = to_raw_value(&serde_json::json!({
"type": "object",
"properties": {
"user_message": { "type": "string" },
},
"required": ["user_message"],
"additionalProperties": false,
}));
}
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
const HARD_MAX_AGENT_ITERATIONS: usize = 1000;
fn find_module_by_id(
modules: &Vec<FlowModule>,
target_id: &str,
) -> Result<Option<FlowModule>, Error> {
let mut found: Option<FlowModule> = None;
FlowModule::traverse_modules(modules, &mut |module| {
if found.is_none() && module.id == target_id {
found = Some(module.clone());
}
Ok(())
})
.map_err(|e| Error::internal_err(format!("Failed to traverse flow modules: {e}")))?;
Ok(found)
}
fn find_ai_agent_tool_module_in_parent_agent(
modules: &Vec<FlowModule>,
parent_agent_step_id: &str,
tool_module_id: &str,
) -> Result<Option<FlowModule>, Error> {
let Some(parent_agent_module) = find_module_by_id(modules, parent_agent_step_id)? else {
return Ok(None);
};
let FlowModuleValue::AIAgent { tools, .. } = parent_agent_module.get_value()? else {
return Ok(None);
};
for tool in tools {
if tool.id == tool_module_id {
return Ok(Option::<FlowModule>::from(&tool));
}
}
Ok(None)
}
pub async fn handle_ai_agent_job(
// connection
conn: &Connection,
@@ -97,7 +142,6 @@ pub async fn handle_ai_agent_job(
canceled_by: &mut Option<CanceledBy>,
mem_peak: &mut i32,
occupancy_metrics: &mut OccupancyMetrics,
job_completed_tx: &JobCompletedSender,
worker_dir: &str,
base_internal_url: &str,
worker_name: &str,
@@ -117,26 +161,57 @@ pub async fn handle_ai_agent_job(
return handle_credentials_check(&args.provider).await;
}
let Some(flow_step_id) = &job.flow_step_id else {
return Err(Error::internal_err(
"AI agent job has no flow step id".to_string(),
));
};
// flow_step_id is set by the flow executor for top-level AI agents.
// For nested AI agent tools, it's not set (to avoid triggering flow step
// machinery on a parent that has no v2_job_status row), so we extract the
// tool module ID from the runnable_path which has the form ".../tools/{id}".
let flow_step_id = job
.flow_step_id
.as_deref()
.or_else(|| job.runnable_path().rsplit_once("/tools/").map(|(_, id)| id))
.ok_or_else(|| Error::internal_err("AI agent job has no flow step id".to_string()))?
.to_string();
let flow_step_id = &flow_step_id;
let Some(parent_job) = &job.parent_job else {
let Some(immediate_parent_job) = &job.parent_job else {
return Err(Error::internal_err(
"AI agent job has no parent job".to_string(),
));
};
let flow_job = get_flow_job_runnable_and_raw_flow(db, &parent_job).await?;
let mut flow_job_id = *immediate_parent_job;
let mut flow_job = get_flow_job_runnable_and_raw_flow(db, &flow_job_id).await?;
let direct_parent_job_kind = flow_job.kind;
let direct_parent_job_flow_step_id = flow_job.flow_step_id.clone();
// If the direct parent is an AI agent (nested tool case), go one level up to the flow.
if flow_job.kind == JobKind::AIAgent {
let Some(parent_job_id) = flow_job.parent_job else {
return Err(Error::internal_err(
"AI agent parent has no parent job".to_string(),
));
};
flow_job_id = parent_job_id;
flow_job = get_flow_job_runnable_and_raw_flow(db, &flow_job_id).await?;
if !matches!(
flow_job.kind,
JobKind::Flow | JobKind::FlowNode | JobKind::FlowPreview
) {
return Err(Error::internal_err(
"AI agent nesting beyond 2 levels is not supported. \
Only flow → agent → nested agent tool is allowed."
.to_string(),
));
}
}
let flow_data = match flow_job.kind {
JobKind::Flow | JobKind::FlowNode => {
cache::job::fetch_flow(db, &flow_job.kind, flow_job.runnable_id).await?
}
JobKind::FlowPreview => {
cache::job::fetch_preview_flow(db, &parent_job, flow_job.raw_flow).await?
cache::job::fetch_preview_flow(db, &flow_job_id, flow_job.raw_flow).await?
}
_ => {
return Err(Error::internal_err(
@@ -147,8 +222,18 @@ pub async fn handle_ai_agent_job(
let value = flow_data.value();
let module = value.modules.iter().find(|m| m.id == *flow_step_id);
let summary = module.as_ref().and_then(|m| m.summary.clone());
let module = if direct_parent_job_kind == JobKind::AIAgent {
let parent_agent_step_id = direct_parent_job_flow_step_id.as_deref().ok_or_else(|| {
Error::internal_err("Parent AI agent job has no flow_step_id".to_string())
})?;
find_ai_agent_tool_module_in_parent_agent(
&value.modules,
parent_agent_step_id,
flow_step_id,
)?
} else {
find_module_by_id(&value.modules, flow_step_id)?
};
let Some(module) = module else {
return Err(Error::internal_err(
@@ -156,6 +241,8 @@ pub async fn handle_ai_agent_job(
));
};
let summary = module.summary.clone();
let FlowModuleValue::AIAgent { tools, .. } = module.get_value()? else {
return Err(Error::internal_err(
"AI agent module is not an AI agent".to_string(),
@@ -285,6 +372,16 @@ pub async fn handle_ai_agent_job(
let schema = Some(parse_raw_script_schema(&content, &language)?);
(schema, input_transforms)
}
FlowModuleValue::AIAgent { input_transforms, .. } => {
// By convention for AIAgent tools, only user_message is expected to be AI-filled.
(
Some(
RawValue::from_string(AI_AGENT_TOOL_SCHEMA.get().to_string())
.expect("AI_AGENT_TOOL_SCHEMA should always be valid JSON"),
),
input_transforms,
)
}
_ => {
return Err(Error::internal_err(format!(
"Unsupported tool: {}",
@@ -342,18 +439,24 @@ pub async fn handle_ai_agent_job(
stream_notifier.update_flow_status_with_stream_job();
}
let flow_status_job = if direct_parent_job_kind == JobKind::AIAgent {
None
} else {
Some(flow_job_id)
};
let agent_fut = run_agent(
db,
conn,
job,
parent_job,
flow_status_job.as_ref(),
Some(flow_step_id.as_str()),
&args,
&tools,
&mcp_clients,
summary.as_deref(),
client,
&mut inner_occupancy_metrics,
job_completed_tx,
worker_dir,
base_internal_url,
worker_name,
@@ -391,7 +494,8 @@ pub async fn run_agent(
// agent job and flow data
job: &MiniPulledJob,
parent_job: &Uuid,
parent_job: Option<&Uuid>,
flow_step_id_override: Option<&str>,
args: &AIAgentArgs,
tools: &[Tool],
mcp_clients: &HashMap<String, Arc<McpClient>>,
@@ -400,7 +504,6 @@ pub async fn run_agent(
// job execution context
client: &AuthedClient,
occupancy_metrics: &mut OccupancyMetrics,
job_completed_tx: &JobCompletedSender,
worker_dir: &str,
base_internal_url: &str,
worker_name: &str,
@@ -433,6 +536,10 @@ pub async fn run_agent(
vec![]
};
// Effective flow_step_id: override for nested agents, otherwise from job
let effective_flow_step_id: Option<&str> =
flow_step_id_override.or(job.flow_step_id.as_deref());
// Fetch flow context for input transforms context, chat and memory
let mut flow_context = get_flow_context(db, job).await;
@@ -479,7 +586,7 @@ pub async fn run_agent(
}
Some(Memory::Auto { context_length, .. }) => {
// Auto mode: load from memory
if let Some(step_id) = job.flow_step_id.as_deref() {
if let Some(step_id) = effective_flow_step_id {
if let Some(memory_id) = memory_id {
// Read messages from memory
match read_from_memory(db, &job.workspace_id, memory_id, step_id).await {
@@ -534,9 +641,8 @@ pub async fn run_agent(
let id_context = {
if let Some(ref flow_status) = flow_context.flow_status {
// Get the step ID from the AI agent's flow step
let previous_id = job
.flow_step_id
.clone()
let previous_id = effective_flow_step_id
.map(str::to_string)
.unwrap_or_else(|| "unknown".to_string());
Some(get_transform_context(job, &previous_id, flow_status))
@@ -649,7 +755,7 @@ pub async fn run_agent(
.and_then(|fs| fs.chat_input_enabled)
.unwrap_or(false);
let step_name = get_step_name_from_flow(summary.as_deref(), job.flow_step_id.as_deref());
let step_name = get_step_name_from_flow(summary.as_deref(), effective_flow_step_id);
let max_iterations = args
.max_iterations
@@ -881,8 +987,11 @@ pub async fn run_agent(
..Default::default()
});
update_flow_status_module_with_actions(db, parent_job, &actions).await?;
update_flow_status_module_with_actions_success(db, parent_job, true).await?;
if let Some(parent_job) = parent_job {
update_flow_status_module_with_actions(db, parent_job, &actions).await?;
update_flow_status_module_with_actions_success(db, parent_job, true)
.await?;
}
content = Some(OpenAIContent::Text(response_content.clone()));
@@ -940,13 +1049,13 @@ pub async fn run_agent(
job,
parent_job,
summary: &summary,
flow_step_id_override,
client,
worker_dir,
base_internal_url,
worker_name,
hostname,
occupancy_metrics,
job_completed_tx,
killpill_rx,
stream_event_processor: stream_event_processor.as_ref(),
flow_context: &mut flow_context,
@@ -1071,7 +1180,7 @@ pub async fn run_agent(
// final_messages contains the complete history (old messages + new ones)
if matches!(output_type, OutputType::Text) && !use_manual_messages {
if let Some(Memory::Auto { context_length, .. }) = &args.memory {
if let Some(step_id) = job.flow_step_id.as_deref() {
if let Some(step_id) = effective_flow_step_id {
// Extract OpenAIMessages from final_messages
let all_messages: Vec<OpenAIMessage> =
final_messages.iter().map(|m| m.message.clone()).collect();
-1
View File
@@ -3398,7 +3398,6 @@ pub async fn handle_queued_job(
&mut canceled_by,
&mut mem_peak,
&mut *occupancy_metrics,
&job_completed_tx,
worker_dir,
base_internal_url,
worker_name,
@@ -65,6 +65,7 @@
otherArgs?: Record<string, InputTransform>
helperScript?: DynamicInputTypes.HelperScript | undefined
isAgentTool?: boolean
allowedAiTransforms?: string[] | undefined
s3StorageConfigured?: boolean
chatInputEnabled?: boolean
}
@@ -92,6 +93,7 @@
otherArgs = {},
helperScript = undefined,
isAgentTool = false,
allowedAiTransforms = isAgentTool ? undefined : [],
s3StorageConfigured = true,
chatInputEnabled = false
}: Props = $props()
@@ -135,6 +137,11 @@
)
)
// Whether this specific field is allowed to use AI transforms
let fieldAllowsAi = $derived(
allowedAiTransforms === undefined || allowedAiTransforms.includes(argName)
)
let propertyType = $state(getPropertyType(arg))
function setExpr() {
@@ -167,7 +174,7 @@
function getPropertyType(arg: InputTransform | any): InputTransform['type'] {
// For agent tools, if static with undefined/empty value, treat as 'ai', meaning the field will be filled by the AI agent dynamically.
if (
isAgentTool &&
fieldAllowsAi &&
((arg?.type === 'static' && arg?.value === undefined) || arg?.type === 'ai')
) {
if (arg?.type === 'static') {
@@ -645,7 +652,7 @@
}}
>
{#snippet children({ item })}
{#if isAgentTool}
{#if fieldAllowsAi}
<ToggleButton
small
label="AI"
@@ -733,8 +740,8 @@
<div
class="text-sm text-tertiary italic p-3 bg-surface-secondary rounded-md border border-gray-200"
>
<span class="flex items-center gap-2">
<InfoIcon size={16} />
<span class="flex items-center gap-2 text-xs">
<InfoIcon size={13} />
This field will be filled by the AI agent dynamically
</span>
</div>
@@ -26,6 +26,7 @@
class?: string
helperScript?: DynamicInputTypes.HelperScript
isAgentTool?: boolean
allowedAiTransforms?: string[] | undefined
chatInputEnabled?: boolean
}
@@ -42,6 +43,7 @@
class: clazz = '',
helperScript = undefined,
isAgentTool = false,
allowedAiTransforms = isAgentTool ? undefined : [],
chatInputEnabled = false
}: Props = $props()
@@ -141,6 +143,7 @@
{enableAi}
{helperScript}
{isAgentTool}
{allowedAiTransforms}
{s3StorageConfigured}
{chatInputEnabled}
otherArgs={Object.fromEntries(
@@ -1,8 +1,16 @@
import type { AiAgent, FlowModule, FlowModuleValue } from '$lib/gen'
import type { AiAgent, FlowModule, FlowModuleValue, InputTransform } from '$lib/gen'
import { loadStoredConfig } from '../aiProviderStorage'
import { AI_AGENT_SCHEMA } from './flowInfers'
export const SPECIAL_TOOL_KINDS = ['mcpTool', 'websearchTool', 'aiAgentTool'] as const
export type SpecialToolKind = (typeof SPECIAL_TOOL_KINDS)[number]
// Type aliases for better readability
export type AgentTool = AiAgent['tools'][number]
export type FlowModuleTool = AgentTool & { value: { tool_type: 'flowmodule' } & FlowModuleValue }
export type AiAgentTool = AgentTool & {
value: { tool_type: 'flowmodule' } & { type: 'aiagent' } & FlowModuleValue
}
export type McpTool = AgentTool & {
value: {
tool_type: 'mcp'
@@ -38,6 +46,39 @@ export function isWebsearchTool(tool: AgentTool): tool is WebsearchTool {
return tool.value.tool_type === 'websearch'
}
/**
* Create an AI Agent tool (nested agent)
*/
export function createAiAgentTool(id: string): AiAgentTool {
const input_transforms: AiAgent['input_transforms'] = {
provider: {
type: 'static',
value: loadStoredConfig() ?? { kind: 'openai', resource: '', model: '' }
},
output_type: { type: 'static', value: 'text' },
user_message: { type: 'ai' }
}
for (const key of Object.keys(AI_AGENT_SCHEMA.properties ?? {})) {
if (!(key in input_transforms)) {
;(input_transforms as Record<string, InputTransform>)[key] = {
type: 'static',
value: undefined
}
}
}
return {
id,
summary: '',
value: {
tool_type: 'flowmodule',
type: 'aiagent',
tools: [],
input_transforms
}
} as AiAgentTool
}
/**
* Create an MCP tool from resource path
*/
@@ -67,6 +108,19 @@ export function createWebsearchTool(id: string): WebsearchTool {
}
}
/**
* Convert a FlowModuleTool to a FlowModule for use with loadFlowModuleState etc.
* Strips the extra `tool_type` field and maps AgentTool fields to FlowModule fields.
*/
export function agentToolToFlowModule(tool: FlowModuleTool): FlowModule {
const { tool_type: _, ...value } = tool.value
return {
id: tool.id,
summary: tool.summary,
value: value as FlowModuleValue
}
}
/**
* Convert a FlowModule back to an AgentTool
* Used when saving changes back to the AI Agent tools array
@@ -1,6 +1,11 @@
<script lang="ts">
import type { AgentTool } from '../agentToolUtils'
import { isFlowModuleTool, isMcpTool, isWebsearchTool, type McpTool } from '../agentToolUtils'
import {
isFlowModuleTool,
isMcpTool,
isWebsearchTool,
type McpTool
} from '../agentToolUtils'
import type { FlowModule } from '$lib/gen'
import FlowModuleComponent from './FlowModuleComponent.svelte'
import McpToolEditor from './McpToolEditor.svelte'
@@ -1061,6 +1061,9 @@
extraLib={stepPropPicker.extraLib}
{enableAi}
{isAgentTool}
allowedAiTransforms={isAgentTool && flowModule.value.type === 'aiagent'
? ['user_message']
: undefined}
helperScript={retrieveDynCodeAndLang(flowModule.value)}
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
/>
@@ -1,5 +1,6 @@
import type { Schema } from '$lib/common'
import type { Flow, FlowModule } from '$lib/gen'
import { isFlowModuleTool, agentToolToFlowModule } from './agentToolUtils'
import { loadFlowModuleState } from './flowStateUtils.svelte'
import { emptyFlowModuleState } from './utils.svelte'
import type { StateStore } from '$lib/utils'
@@ -58,6 +59,14 @@ async function mapFlowModule(flowModule: FlowModule, modulesState: FlowState) {
)
}
if (value.type === 'aiagent' && value.tools) {
await Promise.all(
value.tools.filter(isFlowModuleTool).map(async (tool) => {
modulesState[tool.id] = await loadFlowModuleState(agentToolToFlowModule(tool))
})
)
}
if (value.type === 'identity') {
modulesState[flowModule.id] = emptyFlowModuleState()
} else {
@@ -41,10 +41,15 @@
import type { StateStore } from '$lib/utils'
import {
type AgentTool,
type SpecialToolKind,
flowModuleToAgentTool,
createMcpTool,
createWebsearchTool
createWebsearchTool,
createAiAgentTool,
SPECIAL_TOOL_KINDS,
agentToolToFlowModule
} from '../agentToolUtils'
import { loadFlowModuleState } from '../flowStateUtils.svelte'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
interface Props {
@@ -123,7 +128,7 @@
wsScript?: { path: string; summary: string; hash: string | undefined },
wsFlow?: { path: string; summary: string },
inlineScript?: InlineScript,
toolKind?: 'mcpTool' | 'flowmoduleTool' | 'websearchTool'
toolKind?: SpecialToolKind | 'flowmoduleTool'
): Promise<FlowModule[] | AgentTool[]> {
push(history, flowStore.val)
let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow')
@@ -185,6 +190,12 @@
const websearchTool = createWebsearchTool(module.id)
;(modules as AgentTool[]).splice(index, 0, websearchTool)
return modules as AgentTool[]
} else if (toolKind === 'aiAgentTool') {
// Create AI Agent tool (nested agent)
const aiAgentTool = createAiAgentTool(module.id)
flowStateStore.val[module.id] = await loadFlowModuleState(agentToolToFlowModule(aiAgentTool))
;(modules as AgentTool[]).splice(index, 0, aiAgentTool)
return modules as AgentTool[]
} else if (toolKind === 'flowmoduleTool') {
// Create AgentTool from FlowModule
const agentTool = flowModuleToAgentTool(module)
@@ -318,7 +329,6 @@
noteMode = !noteMode
}
const dispatch = createEventDispatcher<{
generateStep: { moduleId: string; instructions: string; lang: ScriptLang }
change: void
@@ -535,11 +545,9 @@
}
} else {
const index = (detail.agentId ? targetModules?.length : detail.index) ?? 0
const toolKind = detail.agentId
? detail.kind === 'mcpTool'
? 'mcpTool'
: detail.kind === 'websearchTool'
? 'websearchTool'
const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = detail.agentId
? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind)
? (detail.kind as SpecialToolKind)
: 'flowmoduleTool'
: undefined
@@ -102,6 +102,13 @@
dispatch('close')
}}
/>
<TopLevelNode
label="AI Agent"
onSelect={() => {
dispatch('pickAiAgentTool')
dispatch('close')
}}
/>
{:else}
{#if customUi?.triggers != false && allowTrigger}
<TopLevelNode
@@ -22,6 +22,7 @@ export type InsertKind =
| 'aiagent'
| 'mcpTool'
| 'websearchTool'
| 'aiAgentTool'
export type InlineScript = {
language: RawScript['language']
@@ -264,7 +264,7 @@
NewAiToolN,
NodeLayout
} from '../../graphBuilder.svelte'
import { Globe, MessageCircle, Play, Plug, Wrench, X } from 'lucide-svelte'
import { Bot, Globe, MessageCircle, Play, Plug, Wrench, X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { Edge, Node } from '@xyflow/svelte'
@@ -317,6 +317,8 @@
<Globe size={16} class="ml-1 shrink-0" />
{:else if data.type === 'mcp'}
<Plug size={16} class="ml-1 shrink-0" />
{:else if data.type === 'aiagent'}
<Bot size={16} class="ml-1 shrink-0" />
{:else}
<Wrench size={16} class="ml-1 shrink-0" />
{/if}
@@ -99,6 +99,14 @@
})
close()
}}
on:pickAiAgentTool={(e) => {
data.eventHandlers.insert({
index: -1,
agentId: data.agentModuleId,
kind: 'aiAgentTool'
})
close()
}}
/>
{/snippet}
</Popover>