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}`}