From 12faa3384f12a12b8deee2bdc55fd470d3f89cae Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 30 Sep 2026 16:46:47 +0200 Subject: [PATCH] refactor: one builder for an agent's run flow, and the agent page on the shared header The run endpoint and evals build the agent's one-step flow through the same function. The agent page uses DetailPageHeader, whose error handler, tag and trigger context are now optional, and whose menu items keep their disabled state. The home row and the page share the agent's menu and delete. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/windmill-api/src/agent_runs.rs | 43 +--- backend/windmill-api/src/ai_evals/run.rs | 47 ++-- .../components/common/table/AgentRow.svelte | 72 ++---- .../details/DetailPageHeader.svelte | 29 ++- .../src/lib/components/flows/agentActions.ts | 53 +++++ .../agents/get/[...path]/+page.svelte | 218 ++++++++---------- 6 files changed, 229 insertions(+), 233 deletions(-) create mode 100644 frontend/src/lib/components/flows/agentActions.ts diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs index 065c3815fb..56990a3744 100644 --- a/backend/windmill-api/src/agent_runs.rs +++ b/backend/windmill-api/src/agent_runs.rs @@ -23,8 +23,7 @@ use windmill_common::{ }; use windmill_queue::{push, PushArgs, PushIsolationLevel}; -use crate::ai_evals::run::config_to_draft; -use crate::ai_evals::subject::AgentDraft; +use crate::ai_evals::run::{agent_step_flow, config_to_draft}; use crate::db::{ApiAuthed, DB}; use crate::jobs::{handle_chat_conversation_messages, set_flow_memory_id, RunJobQuery}; @@ -74,7 +73,14 @@ pub(crate) async fn run_agent( } else { path.to_string() }; - let flow_value = agent_flow(&config, chat)?; + // A step memory id would replace the conversation's, and the agent would forget the turns + // before; history comes from the conversation alone. + let flow_value = agent_step_flow( + &config, + AGENT_NODE_ID, + chat, + &["memory_id", "previous_messages"], + )?; let push_authed = authed.clone().into(); let (uuid, mut tx) = push( @@ -136,34 +142,3 @@ pub(crate) async fn run_agent( Ok((StatusCode::CREATED, uuid.to_string())) } - -/// The agent as a one-module flow reading the message and files from the run's inputs, shaped -/// through `FlowValue` rather than trusted as raw JSON. -fn agent_flow(config: &AgentDraft, chat: bool) -> Result { - let mut input_transforms = match &config.input_transforms { - serde_json::Value::Object(map) => map.clone(), - _ => serde_json::Map::new(), - }; - // A step memory id would replace the conversation's, and the agent would forget the turns - // before; history comes from the conversation alone. - for key in ["memory_id", "previous_messages"] { - input_transforms.remove(key); - } - for key in ["user_message", "user_attachments"] { - input_transforms.insert( - key.to_string(), - serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }), - ); - } - Ok(serde_json::from_value(serde_json::json!({ - "chat_input_enabled": chat, - "modules": [{ - "id": AGENT_NODE_ID, - "value": { - "type": "aiagent", - "tools": config.tools, - "input_transforms": serde_json::Value::Object(input_transforms), - } - }] - }))?) -} diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index 032d060715..3e5179be7e 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -248,7 +248,7 @@ fn build_run_flow( /// The agent step, reading its case from the iteration rather than from the flow's arguments. fn agent_module(config: &AgentDraft) -> Result { - let flow = build_case_flow(config)?; + let flow = agent_step_flow(config, AGENT_NODE_ID, false, &[])?; let mut value = serde_json::to_value(&flow.modules[0].value)?; if let Some(map) = value.as_object_mut() { let transforms = map @@ -269,34 +269,43 @@ fn agent_module(config: &AgentDraft) -> Result { Ok(serde_json::json!({ "id": AGENT_NODE_ID, "value": value })) } -/// The agent step as a one-module flow, so the module shape is validated by deserializing -/// through `FlowValue` rather than trusted as raw JSON. -fn build_case_flow(config: &AgentDraft) -> Result { - // The configuration runs exactly as authored: its own brain transforms are the module's, and - // the case supplies the message and the attachments over the top. +/// The agent as a one-step flow, validated by deserializing through `FlowValue` rather than +/// trusted as raw JSON. Its own transforms are the step's, minus `without`, and the run's inputs +/// supply the message and the attachments over the top. Always inlined, never a link to the +/// resource: a linked step would resolve the agent when it runs, not as it was read here. +pub(crate) fn agent_step_flow( + config: &AgentDraft, + id: &str, + chat: bool, + without: &[&str], +) -> Result { let mut input_transforms = match &config.input_transforms { serde_json::Value::Object(map) => map.clone(), _ => serde_json::Map::new(), }; + for key in without { + input_transforms.remove(*key); + } for key in ["user_message", "user_attachments"] { input_transforms.insert( key.to_string(), serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }), ); } - - // Always inlined, never a link to the resource: a linked step would resolve the agent when - // each case runs, which is the one thing a run of a named version must not do. - let mut agent_value = serde_json::Map::new(); - agent_value.insert("type".to_string(), serde_json::json!("aiagent")); - agent_value.insert("tools".to_string(), serde_json::json!(config.tools)); - agent_value.insert( - "input_transforms".to_string(), - serde_json::Value::Object(input_transforms), - ); - Ok(serde_json::from_value(serde_json::json!({ - "modules": [{ "id": AGENT_NODE_ID, "value": serde_json::Value::Object(agent_value) }] - }))?) + let mut flow = serde_json::json!({ + "modules": [{ + "id": id, + "value": { + "type": "aiagent", + "tools": config.tools, + "input_transforms": serde_json::Value::Object(input_transforms), + } + }] + }); + if chat { + flow["chat_input_enabled"] = serde_json::json!(true); + } + Ok(serde_json::from_value(flow)?) } /// How many times the agent has been saved, not the identity of the row holding that value: runs diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index 5b300b70d8..da25c3aa6b 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -6,16 +6,14 @@ import type ShareModal from '$lib/components/ShareModal.svelte' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' import InheritedLabels from '$lib/components/InheritedLabels.svelte' - import { ResourceService, type ListableResource } from '$lib/gen' + import type { ListableResource } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' - import { sendUserToast } from '$lib/toast' - import { isDeployable } from '$lib/utils_deployable' - import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import { createEventDispatcher } from 'svelte' - import { FileUp, Pen, Shield, Trash } from 'lucide-svelte' + import { Pen } from 'lucide-svelte' import Button from '../button/Button.svelte' import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte' import { keepsManagedMemory } from '$lib/components/flows/agentFormFields' + import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions' import Row from './Row.svelte' /** @@ -46,14 +44,8 @@ const dispatch = createEventDispatcher() - async function deleteAgent(path: string): Promise { - try { - await ResourceService.deleteResource({ workspace: $workspaceStore!, path }) - sendUserToast(`Deleted agent ${path}`) - dispatch('change') - } catch (err) { - sendUserToast(`Could not delete agent ${path}: ${err}`, true) - } + async function remove(path: string) { + if (await deleteAgent($workspaceStore!, path)) dispatch('change') } let editHref = $derived(`${base}/agents/edit/${agent.path}`) @@ -106,48 +98,20 @@ {/if} { - const { path } = agent - return [ - { - displayName: 'Permissions', - icon: Shield, - // A draft-only agent has no resource yet to hold permissions. - disabled: !agent.canWrite || Boolean(agent.draft_only), - action: () => { - shareModal.openDrawer?.(path, 'resource') - } - }, - ...(!agent.ws_specific && - !agent.draft_only && - isDeployable('resource', path, await getDeployUiSettings()) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer.openDrawer(path, 'resource') - } - } - ] - : []), - { - displayName: 'Delete', - icon: Trash, - type: 'delete' as const, - disabled: !agent.canWrite, - action: (event) => { - if (event?.shiftKey) { - deleteAgent(path) - } else { - deleteConfirmedCallback = () => { - deleteAgent(path) - } - } - } + items={() => + agentMenuItems({ + path: agent.path, + canWrite: agent.canWrite, + draftOnly: Boolean(agent.draft_only), + wsSpecific: agent.ws_specific, + onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'), + onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'), + onDelete: (event) => { + const { path } = agent + if (event?.shiftKey) remove(path) + else deleteConfirmedCallback = () => remove(path) } - ] - }} + })} on:open={() => { menuOpen = true }} diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 4f91f1800f..1baf149c1c 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -29,26 +29,33 @@ type MenuItemButton = { label: string Icon: any - onclick: () => void + onclick: (e: MouseEvent) => void color?: 'red' + disabled?: boolean } - const { triggersCount, triggersState } = $state(getContext('TriggerContext')) + // An agent's page has no triggers, and so no context for them. + const { triggersCount, triggersState } = $state( + getContext('TriggerContext') ?? ({} as Partial) + ) interface Props { mainButtons?: MainButton[] menuItems?: MenuItemButton[] summary?: string path?: string - tag: string | undefined - errorHandlerKind: 'flow' | 'script' - scriptOrFlowPath: string - errorHandlerMuted: boolean | undefined + tag?: string | undefined + /** Unset for what has no workspace error handler to mute, such as an agent. */ + errorHandlerKind?: 'flow' | 'script' + scriptOrFlowPath?: string + errorHandlerMuted?: boolean | undefined labels?: string[] | undefined inheritedLabels?: string[] | undefined onSaved?: (newPath: string) => void children?: import('svelte').Snippet trigger_badges?: import('svelte').Snippet + /** Controls ahead of the menu, such as the way an agent's page runs it. */ + leading_actions?: import('svelte').Snippet } let { @@ -64,7 +71,8 @@ inheritedLabels = undefined, onSaved, children, - trigger_badges + trigger_badges, + leading_actions }: Props = $props() const dispatch = createEventDispatcher() @@ -81,6 +89,7 @@ } async function toggleErrorHandler() { + if (!errorHandlerKind || !scriptOrFlowPath) return const next = await toggleWorkspaceErrorHandler( errorHandlerKind, scriptOrFlowPath, @@ -99,7 +108,7 @@ disabled: b.buttonProps.disabled, type: 'action' as const })), - ...(wide.current + ...(wide.current || !errorHandlerKind ? [] : [ { @@ -113,6 +122,7 @@ displayName: item.label, icon: item.Icon, action: item.onclick, + disabled: item.disabled, type: item.color === 'red' ? ('delete' as const) : ('action' as const), separatorTop: i === 0 && !wide.current })) @@ -176,12 +186,13 @@ {@render trigger_badges?.()}
+ {@render leading_actions?.()} {#if allMenuItems.length > 0} {#key allMenuItems} {/key} {/if} - {#if wide.current} + {#if wide.current && errorHandlerKind && scriptOrFlowPath} { + try { + await ResourceService.deleteResource({ workspace, path }) + sendUserToast(`Deleted agent ${path}`) + return true + } catch (err) { + sendUserToast(`Could not delete agent ${path}: ${err}`, true) + return false + } +} + +/** An agent's menu, as its home row and its page both show it. */ +export async function agentMenuItems(agent: { + path: string + canWrite: boolean + /** Never deployed: no resource yet to hold permissions or to deploy onward. */ + draftOnly?: boolean + wsSpecific?: boolean + onPermissions: () => void + onDeploy: () => void + onDelete: (event?: MouseEvent) => void +}): Promise { + const deployable = + !agent.wsSpecific && + !agent.draftOnly && + isDeployable('resource', agent.path, await getDeployUiSettings()) + return [ + { + displayName: 'Permissions', + icon: Shield, + disabled: !agent.canWrite || Boolean(agent.draftOnly), + action: agent.onPermissions + }, + ...(deployable + ? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }] + : []), + { + displayName: 'Delete', + icon: Trash, + type: 'delete' as const, + disabled: !agent.canWrite, + action: agent.onDelete + } + ] +} diff --git a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte index 4815184805..42462136ac 100644 --- a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte @@ -1,14 +1,12 @@ @@ -65,113 +83,79 @@ on:canceled={() => (deleteOpen = false)} on:confirmed={() => { deleteOpen = false - deleteAgent() + remove() }} > Every flow that links {path} will fail at its agent step once it is deleted.
- -
-
-
-
- -
-
-
- - {#if chatAvailable && testPane?.mode} - testPane?.mode, - (mode) => { - if (testPane) testPane.mode = mode + evalsModal?.openModal() + } + }, + { + label: 'Edit', + buttonProps: { + variant: 'accent', + unifiedSize: 'md', + startIcon: Pen, + href: `${base}/agents/edit/${path}` } } - noWFull - > - {#snippet children({ item })} - - - {/snippet} - - {/if} - {#if config && !$userStore?.operator} - [ - { - displayName: 'Permissions', - icon: Shield, - disabled: !canEdit, - action: () => shareModal?.openDrawer?.(path, 'resource') - }, - ...(!agent?.state?.wsSpecific && - isDeployable('resource', path, await getDeployUiSettings()) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => deploymentDrawer?.openDrawer(path, 'resource') - } - ] - : []), - { - displayName: 'Delete', - icon: Trash, - type: 'delete' as const, - disabled: !canEdit, - action: () => (deleteOpen = true) - } - ]} - /> - - {#if canEdit} - - {/if} - {/if} - {#if canEdit} - - {/if} -
-
-
+ ] + : []) + ]} + > + {#snippet leading_actions()} + + {#if chatAvailable && testPane?.mode} + testPane?.mode, + (mode) => { + if (testPane) testPane.mode = mode + } + } + noWFull + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {/snippet} +
{#key `${ws}:${path}`}