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) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-30 16:46:47 +02:00
co-authored by Claude Opus 5.5
parent 96d0a6e62b
commit 12faa3384f
6 changed files with 229 additions and 233 deletions
+9 -34
View File
@@ -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<windmill_common::flows::FlowValue> {
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),
}
}]
}))?)
}
+28 -19
View File
@@ -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<serde_json::Value> {
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<serde_json::Value> {
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<windmill_common::flows::FlowValue> {
// 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<windmill_common::flows::FlowValue> {
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
@@ -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<void> {
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 @@
</span>
{/if}
<Dropdown
items={async () => {
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
}}
@@ -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>('TriggerContext'))
// An agent's page has no triggers, and so no context for them.
const { triggersCount, triggersState } = $state(
getContext<TriggerContext | undefined>('TriggerContext') ?? ({} as Partial<TriggerContext>)
)
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?.()}
</div>
<div class="flex gap-1 items-center pr-4">
{@render leading_actions?.()}
{#if allMenuItems.length > 0}
{#key allMenuItems}
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
{/key}
{/if}
{#if wide.current}
{#if wide.current && errorHandlerKind && scriptOrFlowPath}
<ErrorHandlerToggleButton
kind={errorHandlerKind}
{scriptOrFlowPath}
@@ -0,0 +1,53 @@
import { FileUp, Shield, Trash } from 'lucide-svelte'
import { ResourceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import type { Item } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
/** Deletes a saved agent, saying how it went. Resolves whether it was deleted. */
export async function deleteAgent(workspace: string, path: string): Promise<boolean> {
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<Item[]> {
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
}
]
}
@@ -1,14 +1,12 @@
<script lang="ts">
import { page } from '$app/state'
import { FileUp, FlaskConical, FormInput, MessageSquare, Pen, Shield, Trash } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte'
import { resource } from 'runed'
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import { copilotInfo } from '$lib/aiStore'
import { Button } from '$lib/components/common'
import AgentEvalsModal from '$lib/components/flows/content/AgentEvalsModal.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
@@ -18,11 +16,8 @@
import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { ResourceService } from '$lib/gen'
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { isDeployable } from '$lib/utils_deployable'
/**
* The deployed agent, as a flow's or a script's page shows theirs: a way to run it, a chat when
@@ -44,16 +39,39 @@
let deleteOpen = $state(false)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
async function deleteAgent() {
if (!ws) return
try {
await ResourceService.deleteResource({ workspace: ws, path })
sendUserToast(`Deleted agent ${path}`)
await goto(`${base}/?kind=agent`)
} catch (err) {
sendUserToast(`Could not delete agent ${path}: ${err}`, true)
}
async function remove() {
if (ws && (await deleteAgent(ws, path))) await goto(`${base}/?kind=agent`)
}
// Operators get no menu, as on a flow's or a script's page.
const menuItems = resource(
() =>
[
path,
config != undefined && !$userStore?.operator,
canEdit,
agent?.state?.wsSpecific
] as const,
async ([path, shown, canWrite, wsSpecific]) =>
shown
? (
await agentMenuItems({
path,
canWrite,
wsSpecific,
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
onDelete: () => (deleteOpen = true)
})
).map((item) => ({
label: item.displayName,
Icon: item.icon,
onclick: (e: MouseEvent) => item.action?.(e),
disabled: item.disabled,
color: item.type === 'delete' ? ('red' as const) : undefined
}))
: []
)
</script>
<ShareModal bind:this={shareModal} />
@@ -65,113 +83,79 @@
on:canceled={() => (deleteOpen = false)}
on:confirmed={() => {
deleteOpen = false
deleteAgent()
remove()
}}
>
<span>Every flow that links {path} will fail at its agent step once it is deleted.</span>
</ConfirmationModal>
<main class="h-screen w-full flex flex-col">
<!-- Laid out as `DetailPageHeader` is for flows and scripts, whose trigger and error handler
controls an agent does not have. -->
<div class="border-b">
<div
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12 py-2 md:py-0"
>
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
<div class={twMerge('min-w-0', $userStore?.operator ? 'pl-10' : '')}>
<SummaryPathDisplay summary={config ? agent?.state?.description : undefined} {path} />
</div>
</div>
<div class="flex gap-1 items-center pr-4">
<!-- Only an agent that keeps the conversation can chat: without managed memory every
message would be answered alone, so it is run from its inputs with nothing to switch. -->
{#if chatAvailable && testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
<DetailPageHeader
summary={config ? agent?.state?.description : undefined}
{path}
menuItems={menuItems.current ?? []}
mainButtons={[
// Evaluating an agent builds datasets and runs against it: authoring, as editing is.
...(canEdit
? [
{
label: 'Evals',
buttonProps: {
variant: 'default',
unifiedSize: 'md',
startIcon: FlaskConical,
title: 'Run this agent against a dataset of cases',
onClick: () => evalsModal?.openModal()
}
},
{
label: 'Edit',
buttonProps: {
variant: 'accent',
unifiedSize: 'md',
startIcon: Pen,
href: `${base}/agents/edit/${path}`
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="md"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="md"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{#if config && !$userStore?.operator}
<DropdownV2
placement="bottom-end"
size="md"
items={async () => [
{
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)
}
]}
/>
<!-- Evaluating an agent builds datasets and runs against it: authoring, as editing is. -->
{#if canEdit}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: FlaskConical }}
title="Run this agent against a dataset of cases"
onClick={() => evalsModal?.openModal()}
>
Evals
</Button>
{/if}
{/if}
{#if canEdit}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Pen }}
href="{base}/agents/edit/{path}"
>
Edit
</Button>
{/if}
</div>
</div>
</div>
]
: [])
]}
>
{#snippet leading_actions()}
<!-- Only an agent that keeps the conversation can chat: without managed memory every
message would be answered alone, so it is run from its inputs with nothing to switch. -->
{#if chatAvailable && testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="md"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="md"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{/snippet}
</DetailPageHeader>
<div class="flex-1 min-h-0">
{#key `${ws}:${path}`}
<AgentEditorHost