mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
feat: agent nits: multi-select, memory-off notice, agent step label (#11622)
* feat: agent nits: multi-select, memory-off notice, agent step label Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say saved, not renamed, on a new agent's first deploy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: run the agent page form on cmd+enter, select agents by keyboard Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: pin the agent step label Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0a95ea47fa
commit
c52ba2fd30
@@ -69,6 +69,7 @@
|
||||
retainLinkedToolsScope
|
||||
} from './flows/linkedAgentToolsStore.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { AGENT_STEP_ID, agentStepLabel } from './flows/conversations/agentEditorChat'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
@@ -1238,6 +1239,8 @@
|
||||
id: string
|
||||
type: FlowModuleValue['type']
|
||||
suspend?: boolean
|
||||
/** Shown in place of the id. */
|
||||
label?: string
|
||||
}
|
||||
|
||||
function allModulesForTimeline(
|
||||
@@ -1250,7 +1253,8 @@
|
||||
({
|
||||
id: x.id,
|
||||
type: x.value.type,
|
||||
suspend: x.suspend != undefined
|
||||
suspend: x.suspend != undefined,
|
||||
label: x.id === AGENT_STEP_ID ? agentStepLabel(job?.script_path) : undefined
|
||||
}) as FlowModuleForTimeline,
|
||||
{
|
||||
skipToolNodes: true
|
||||
|
||||
@@ -179,12 +179,12 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#each flowModules as { id: k, type: typ, suspend: isSuspend } (k)}
|
||||
{#each flowModules as { id: k, type: typ, suspend: isSuspend, label } (k)}
|
||||
{@const subItems = items?.[k]?.filter((x) => x.created_at && x.started_at)}
|
||||
<div class="relative px-3 py-1.5">
|
||||
<div class="flex items-center justify-between mb-0.5">
|
||||
<div class="text-xs font-medium"
|
||||
>{k.startsWith('subflow:') ? k.substring(8) : k}
|
||||
>{label ?? (k.startsWith('subflow:') ? k.substring(8) : k)}
|
||||
{#if localModuleStates[k]?.selectedForloop && (typ == 'forloopflow' || typ == 'whileloopflow')}
|
||||
<span class="text-xs font-mono font-medium inline-flex items-center -my-2">
|
||||
<button onclick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import Row from './Row.svelte'
|
||||
import { selectMenuItems, type RowSelection } from './rowSelection'
|
||||
|
||||
/**
|
||||
* A home-page row for a saved agent. The agent is an `ai_agent` resource, so the row opens the
|
||||
@@ -30,6 +31,7 @@
|
||||
depth?: number
|
||||
menuOpen: boolean
|
||||
keyboardSelected?: boolean
|
||||
rowSelection?: RowSelection
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -40,7 +42,8 @@
|
||||
deleteConfirmedCallback = $bindable(),
|
||||
depth = 0,
|
||||
menuOpen = $bindable(),
|
||||
keyboardSelected = false
|
||||
keyboardSelected = false,
|
||||
rowSelection = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -69,6 +72,7 @@
|
||||
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={false}
|
||||
{depth}
|
||||
{rowSelection}
|
||||
>
|
||||
{#snippet badges()}
|
||||
<SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} />
|
||||
@@ -99,8 +103,9 @@
|
||||
</span>
|
||||
{/if}
|
||||
<Dropdown
|
||||
items={async () =>
|
||||
agentMenuItems({
|
||||
items={async () => [
|
||||
...selectMenuItems(rowSelection),
|
||||
...agentMenuItems({
|
||||
deployUiSettings: await getDeployUiSettings(),
|
||||
path: agent.path,
|
||||
canWrite: agent.canWrite,
|
||||
@@ -113,7 +118,8 @@
|
||||
if (event?.shiftKey) remove(path)
|
||||
else deleteConfirmedCallback = () => remove(path)
|
||||
}
|
||||
})}
|
||||
})
|
||||
]}
|
||||
on:open={() => {
|
||||
menuOpen = true
|
||||
}}
|
||||
|
||||
@@ -438,6 +438,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
// The counter the step card's write-back used to report, from the surface that now owns the
|
||||
// write: a deploy here reaches every flow linking this agent.
|
||||
logReusableAgentUsage(noDeployed ? 'saved' : 'updated')
|
||||
// A new agent's editor opens on its draft's storage path, so its first save always lands
|
||||
// elsewhere: that is naming it, not a rename.
|
||||
const firstSave = noDeployed
|
||||
deployed = submitted
|
||||
noDeployed = false
|
||||
const renamed = submitted.path !== fromPath
|
||||
@@ -456,7 +459,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
sync.discard(fromPath, submitted)
|
||||
// A rename moves the row, so the next load must not reuse the old key.
|
||||
loadedFor = `${ws}:${submitted.path}`
|
||||
sendUserToast(renamed ? `Renamed agent to ${submitted.path}` : `Saved agent ${submitted.path}`)
|
||||
sendUserToast(
|
||||
renamed && !firstSave ? `Renamed agent to ${submitted.path}` : `Saved agent ${submitted.path}`
|
||||
)
|
||||
return submitted.path
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
AGENT_CHAT_SCHEMA,
|
||||
agentChatFlow,
|
||||
agentChatGap,
|
||||
agentChatPath
|
||||
agentChatPath,
|
||||
AGENT_STEP_ID
|
||||
} from '../conversations/agentEditorChat'
|
||||
import { runFlowPreview } from '../utils.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
@@ -100,7 +101,7 @@
|
||||
* schema per tool — the linked branch deliberately loads none. Reserved in `forbiddenIds`:
|
||||
* the root shares a flow-state map with the tools, so a tool of this id would lose its
|
||||
* schema to the root's. */
|
||||
const AGENT_ID = '__wm_agent_root'
|
||||
const AGENT_ID = AGENT_STEP_ID
|
||||
|
||||
const draft = useAgentDraft({
|
||||
path: () => path,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { agentChatFlow, agentChatGap, agentChatPath } from './agentEditorChat'
|
||||
import { agentChatFlow, agentChatGap, agentChatPath, agentStepLabel } from './agentEditorChat'
|
||||
|
||||
describe('agentChatPath', () => {
|
||||
it('is a path no flow can take, so a same-path flow never shares its conversations', () => {
|
||||
@@ -10,6 +10,14 @@ describe('agentChatPath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentStepLabel', () => {
|
||||
it("names a run's agent step by the agent, a chat turn's included", () => {
|
||||
expect(agentStepLabel('f/support/agent')).toBe('f/support/agent')
|
||||
expect(agentStepLabel(agentChatPath('f/support/agent'))).toBe('f/support/agent')
|
||||
expect(agentStepLabel(undefined)).toBe('AI Agent')
|
||||
})
|
||||
})
|
||||
|
||||
function agent(inputTransforms: Record<string, any>): FlowModule {
|
||||
return {
|
||||
id: '__wm_agent_root',
|
||||
|
||||
@@ -13,6 +13,15 @@ export function agentChatPath(agentPath: string): string {
|
||||
return `${agentPath}.chat`
|
||||
}
|
||||
|
||||
/** The id of the one step a saved agent's run is made of, as the server names it too: job-read
|
||||
* scoping and the run's agent detection match on it, so it is only ever relabeled for display. */
|
||||
export const AGENT_STEP_ID = '__wm_agent_root'
|
||||
|
||||
/** What a run shows for that step: the agent it ran, read off the run's path. */
|
||||
export function agentStepLabel(runPath: string | undefined): string {
|
||||
return runPath?.replace(/\.chat$/, '') || 'AI Agent'
|
||||
}
|
||||
|
||||
/**
|
||||
* The inputs of the flow a saved agent is chatted with: the message, and the files the composer's
|
||||
* paperclip uploads. `user_message` is the name the server requires of a chat-mode run.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { FlowNodeColorClasses } from '$lib/components/graph'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { getContext } from 'svelte'
|
||||
import { AGENT_STEP_ID } from '../conversations/agentEditorChat'
|
||||
|
||||
// Detached mode (sessions): a step's editor is hidden until asked for, so add the
|
||||
// hint to the name tooltip. Provided by FlowEditor; absent (=> false) elsewhere.
|
||||
@@ -69,7 +70,8 @@
|
||||
</Popover>
|
||||
|
||||
<div class="flex items-center space-x-2 relative" bind:clientWidth={idBadgeWidth}>
|
||||
{#if id && id !== 'preprocessor' && !id.startsWith('failure') && !id.startsWith('subflow:')}
|
||||
<!-- A saved agent's run is that one step, already named by the run page. -->
|
||||
{#if id && id !== 'preprocessor' && id !== AGENT_STEP_ID && !id.startsWith('failure') && !id.startsWith('subflow:')}
|
||||
<Badge
|
||||
color="transparent"
|
||||
class="border-none"
|
||||
|
||||
@@ -45,10 +45,9 @@
|
||||
// A raw app the listing returns is an `app` row carrying `raw_app`, and is
|
||||
// selectable like any other app. The separate `raw_app` type is the legacy
|
||||
// listing shape, which renders through RawAppRow — no selection control there,
|
||||
// so it must not enter the selection either. An agent is a resource underneath,
|
||||
// which none of the bulk actions handle.
|
||||
// so it must not enter the selection either.
|
||||
let bulkItem = $derived(
|
||||
homeSelection?.available && item.type !== 'raw_app' && item.type !== 'agent'
|
||||
homeSelection?.available && item.type !== 'raw_app'
|
||||
? toBulkItem(item, $userStore, $workspaceStore)
|
||||
: undefined
|
||||
)
|
||||
@@ -145,6 +144,7 @@
|
||||
{depth}
|
||||
bind:menuOpen
|
||||
{keyboardSelected}
|
||||
{rowSelection}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1821,8 +1821,7 @@
|
||||
homeSelection.active &&
|
||||
selectedIndex >= 0 &&
|
||||
selectedIndex < displayedItems.length &&
|
||||
displayedItems[selectedIndex].type !== 'raw_app' &&
|
||||
displayedItems[selectedIndex].type !== 'agent'
|
||||
displayedItems[selectedIndex].type !== 'raw_app'
|
||||
) {
|
||||
e.preventDefault()
|
||||
homeSelection.toggle(
|
||||
|
||||
@@ -57,6 +57,15 @@ describe('blockedReason', () => {
|
||||
expect(blockedReason('unarchive', item({ archived: false }), admin)).toBeDefined()
|
||||
})
|
||||
|
||||
// AgentRow's delete is `disabled: !canWrite`; the server moves no resource draft.
|
||||
it('gates an agent as its row does, and keeps a draft-only one in place', () => {
|
||||
const agent = item({ kind: 'agent', owner: false })
|
||||
expect(blockedReason('delete', agent, member)).toBeUndefined()
|
||||
expect(blockedReason('delete', { ...agent, canWrite: false }, member)).toBeDefined()
|
||||
expect(blockedReason('archive', agent, admin)).toBeDefined()
|
||||
expect(blockedReason('move', item({ kind: 'agent', draftOnly: true }), admin)).toBeDefined()
|
||||
})
|
||||
|
||||
it('lets a draft-only row move or discard, never archive/delete', () => {
|
||||
const draftOnly = item({ draftOnly: true, isDraft: true })
|
||||
expect(blockedReason('discard', draftOnly, admin)).toBeUndefined()
|
||||
|
||||
@@ -8,18 +8,19 @@
|
||||
* addresses the deployed row. A draft-only item is therefore not deletable —
|
||||
* there is nothing deployed at its path.
|
||||
*/
|
||||
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
|
||||
import { AppService, DraftService, FlowService, ResourceService, ScriptService } from '$lib/gen'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import { updateItemPathAndSummary } from '$lib/components/moveRenameManager'
|
||||
import { discardDraft } from '$lib/utils_draft_deploy'
|
||||
import type { BulkItem } from './homeSelection.svelte'
|
||||
|
||||
/** The draft overlay is the one place a raw app is its own kind. Narrowed to the
|
||||
* four kinds `moveDraft` accepts — a `BulkItem` is never anything else, and
|
||||
* saying so lets the compiler check that rather than trusting it. */
|
||||
/** The draft overlay is the one place a raw app is its own kind, and an agent's
|
||||
* draft is filed as the resource it is. Narrowed so the compiler checks that a
|
||||
* `BulkItem` never maps to anything else. */
|
||||
function draftKind(
|
||||
item: BulkItem
|
||||
): Extract<UserDraftItemKind, 'script' | 'flow' | 'app' | 'raw_app'> {
|
||||
): Extract<UserDraftItemKind, 'script' | 'flow' | 'app' | 'raw_app' | 'resource'> {
|
||||
if (item.kind === 'agent') return 'resource'
|
||||
return item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind
|
||||
}
|
||||
|
||||
@@ -43,6 +44,9 @@ export function blockedReason(
|
||||
switch (action) {
|
||||
case 'move':
|
||||
if (item.archived) return 'archived items cannot be moved'
|
||||
// The server moves only script, flow and app drafts.
|
||||
if (item.kind === 'agent' && item.draftOnly)
|
||||
return 'a draft-only agent is moved by changing its path in the editor'
|
||||
if (!item.owner) return notOwner
|
||||
if (!item.canWrite) return 'you do not have write permission on this path'
|
||||
return undefined
|
||||
@@ -94,9 +98,11 @@ async function moveItem(ctx: BulkContext, item: BulkItem, target: string): Promi
|
||||
if (item.draftOnly) {
|
||||
// Nothing is deployed at this path, so there is no deploy to re-run: the
|
||||
// item IS its draft row, and moving it rewrites that row.
|
||||
const kind = draftKind(item)
|
||||
if (kind === 'resource') throw new Error('a draft-only agent cannot be moved here')
|
||||
await DraftService.moveDraft({
|
||||
workspace: ctx.workspace,
|
||||
kind: draftKind(item),
|
||||
kind,
|
||||
path: item.path,
|
||||
requestBody: { new_path: newPath }
|
||||
})
|
||||
@@ -138,6 +144,8 @@ async function deleteItem(ctx: BulkContext, item: BulkItem): Promise<void> {
|
||||
await ScriptService.deleteScriptByPath({ workspace: ctx.workspace, path: item.path })
|
||||
} else if (item.kind === 'flow') {
|
||||
await FlowService.deleteFlowByPath({ workspace: ctx.workspace, path: item.path })
|
||||
} else if (item.kind === 'agent') {
|
||||
await ResourceService.deleteResource({ workspace: ctx.workspace, path: item.path })
|
||||
} else {
|
||||
await AppService.deleteApp({ workspace: ctx.workspace, path: item.path })
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { UserExt } from '$lib/stores'
|
||||
/** A raw app is not a kind of its own here: the listing returns it as an `app`
|
||||
* row carrying `raw_app`, which every action addresses through `BulkItem.rawApp`
|
||||
* (only the draft overlay distinguishes the two). */
|
||||
export type BulkKind = 'script' | 'flow' | 'app'
|
||||
export type BulkKind = 'script' | 'flow' | 'app' | 'agent'
|
||||
|
||||
/** The flattened row facts every bulk action needs, snapshotted at selection
|
||||
* time so an action still knows what it is acting on after the list reloads. */
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
|
||||
import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
|
||||
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
/**
|
||||
* The deployed agent, as a flow's or a script's page shows theirs: a way to run it, a chat when
|
||||
@@ -41,6 +43,21 @@
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
|
||||
let deleteOpen = $state(false)
|
||||
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
|
||||
let runForm: RunForm | undefined = $state(undefined)
|
||||
let isValid = $state(true)
|
||||
|
||||
// Cmd/Ctrl+Enter runs the form, as on a script's or a flow's page. The form stays mounted, hidden,
|
||||
// while the chat shows, and the chat's composer owns the key then.
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return
|
||||
if (!runForm || testPane?.mode === 'chat') return
|
||||
if (isValid) {
|
||||
event.preventDefault()
|
||||
runForm.run()
|
||||
} else {
|
||||
sendUserToast('Please fix errors before running', true)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (ws && (await deleteAgent(ws, path))) await goto(`${base}/?kind=agent`)
|
||||
@@ -88,6 +105,8 @@
|
||||
<span>Every flow that links {path} will fail at its agent step once it is deleted.</span>
|
||||
</ConfirmationModal>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
|
||||
<main class="h-full w-full flex flex-col">
|
||||
<!-- `labels`: the pen saves every field it shows in one write, so the labels it shows have to be
|
||||
the ones the resource carries. Handed none, it would offer an empty list, and the first
|
||||
@@ -180,6 +199,14 @@
|
||||
onOpenConfig={() => configModal?.open()}
|
||||
>
|
||||
{#snippet viewForm({ schema, run, loading, actions })}
|
||||
{#if config && !chatAvailable}
|
||||
<Alert type="info" size="xs" title="Managed memory is off" class="mb-4">
|
||||
Each run is answered on its own inputs, so this agent has no chat. That suits reusing
|
||||
it as a step in a flow.{canEdit
|
||||
? ' Turn on managed memory in its configuration to chat with it.'
|
||||
: ''}
|
||||
</Alert>
|
||||
{/if}
|
||||
<RunForm
|
||||
runnable={{ schema, path }}
|
||||
runAction={run}
|
||||
@@ -188,6 +215,8 @@
|
||||
autofocus
|
||||
{loading}
|
||||
{actions}
|
||||
bind:isValid
|
||||
bind:this={runForm}
|
||||
/>
|
||||
{/snippet}
|
||||
</AgentEditorHost>
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
onEditInForkClick
|
||||
} from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { AGENT_STEP_ID } from '$lib/components/flows/conversations/agentEditorChat'
|
||||
let job: (Job & { result?: any; result_stream?: string }) | undefined = $state()
|
||||
let jobUpdateLastFetch: Date | undefined = $state()
|
||||
|
||||
@@ -1144,7 +1145,8 @@
|
||||
showStepId
|
||||
>
|
||||
{#snippet errorAction()}
|
||||
{#if failedTopLevelStep}
|
||||
<!-- A saved agent's run is its one step: naming it adds nothing. -->
|
||||
{#if failedTopLevelStep && failedTopLevelStep !== AGENT_STEP_ID}
|
||||
<span>at step {failedTopLevelStep}</span>
|
||||
{#if $enterpriseLicense && canRestart(failedRestart)}
|
||||
<span>·</span>
|
||||
|
||||
Reference in New Issue
Block a user