diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0dd9f8620e..aa12357c40 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -31710,6 +31710,11 @@ components: (over a deployed row or a synthesized draft-only row). Frontend appends a `*` to the displayed name. type: boolean + agent_memory: + description: | + For an `ai_agent` resource only, its `memory` setting: the one part + of the value a listing returns, since it decides whether the agent + keeps a conversation. required: - path - resource_type diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 443cbdfa14..a9dfd6b5c4 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -206,6 +206,20 @@ pub struct ListableResource { pub is_draft: Option, } +/// A row of the resource listing: the resource as a single read returns it, plus what only the +/// listing carries. +#[derive(FromRow, Serialize)] +pub struct ListedResource { + #[sqlx(flatten)] + #[serde(flatten)] + pub resource: ListableResource, + /// An `ai_agent`'s `memory` setting, the one part of its value a listing shows: whether it + /// keeps a conversation decides how it is offered. `value` stays unlisted for every type. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub agent_memory: Option, +} + #[derive(Deserialize)] pub struct CreateResource { pub path: String, @@ -322,7 +336,7 @@ async fn list_resources( Extension(user_db): Extension, Extension(db): Extension, Path(w_id): Path, -) -> JsonResult> { +) -> JsonResult> { let (per_page, offset) = paginate(pagination); let mut sqlb = SqlBuilder::select_from("resource") @@ -344,6 +358,7 @@ async fn list_resources( "resource.labels", "folder_labels(resource.workspace_id, resource.path) as inherited_labels", "ws_specific.path IS NOT NULL as ws_specific", + "CASE WHEN resource.resource_type = 'ai_agent' THEN resource.value->'memory' END as agent_memory", ]) // Scalar EXISTS flags the authed user's per-user draft without fanning rows out. .field( @@ -428,11 +443,11 @@ async fn list_resources( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "resources", "read"); - let mut rows = sqlx::query_as::<_, ListableResource>(&sql) + let mut rows = sqlx::query_as::<_, ListedResource>(&sql) .fetch_all(&mut *tx) .await? .into_iter() - .filter(|r| allowed(&r.path)) + .filter(|r| allowed(&r.resource.path)) .collect::>(); tx.commit().await?; @@ -503,8 +518,13 @@ async fn list_resources( }) }); let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + let agent_memory = if resource_type == "ai_agent" { + value.as_ref().and_then(|a| a.get("memory")).cloned() + } else { + None + }; - rows.push(ListableResource { + let resource = ListableResource { workspace_id: w_id.clone(), path, value, @@ -526,7 +546,8 @@ async fn list_resources( draft_only: Some(true), // Synthesized rows are the authed user's draft. is_draft: Some(true), - }); + }; + rows.push(ListedResource { resource, agent_memory }); } } diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index 5c455f6686..b3b3e3ef87 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -14,6 +14,8 @@ import { createEventDispatcher } from 'svelte' import { FileUp, Pen, Shield, Trash } 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 Row from './Row.svelte' /** @@ -59,8 +61,13 @@ let rowHref = $derived(agent.draft_only ? editHref : `${base}/agents/get/${agent.path}`) +{#snippet chatBadge()} + +{/snippet} + + import { Badge, Section } from '$lib/components/common' + import { + AGENT_BRAIN_LABELS, + summarizeAgentBrain, + type AIAgentConfig + } from '../agentResourceUtils' + import { toolDisplayName, type AgentTool } from '../agentToolUtils' + + /** A saved agent's configuration, read-only and at a glance: what a detail page shows beside + * running it, where the editor's form would be all fields and no summary. */ + interface Props { + config: AIAgentConfig + } + + let { config }: Props = $props() + + const SYSTEM_PROMPT_LABEL = AGENT_BRAIN_LABELS['system_prompt'] + + let settings = $derived( + summarizeAgentBrain(config).filter((r) => r.label !== SYSTEM_PROMPT_LABEL) + ) + let systemPrompt = $derived( + typeof config.system_prompt === 'string' && config.system_prompt !== '' + ? config.system_prompt + : undefined + ) + let tools = $derived(Array.isArray(config.tools) ? (config.tools as AgentTool[]) : []) + + function toolKind(tool: AgentTool): string { + const value = tool?.value as Record | undefined + if (value?.tool_type === 'mcp') return 'MCP' + if (value?.tool_type === 'websearch') return 'Web search' + switch (value?.type) { + case 'script': + return 'Script' + case 'flow': + return 'Flow' + case 'rawscript': + return 'Inline script' + case 'aiagent': + return 'Agent' + default: + return 'Tool' + } + } + + +
+
+
+ {#each settings as row (row.label)} +
+
{row.label}
+
{row.value}
+
+ {:else} + Not configured + {/each} +
+
+ +
+ {#if systemPrompt} +

+ {systemPrompt} +

+ {:else} + None + {/if} +
+ +
+ {#if tools.length === 0} + No tools + {:else} +
    + {#each tools as tool, i (tool?.id ?? i)} +
  • + {toolDisplayName(tool) ?? tool?.id} + {toolKind(tool)} +
  • + {/each} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index 2bdafdbb8d..6523aa15fb 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -11,6 +11,8 @@ agentChatPath } from '../conversations/agentEditorChat' import { runFlowPreview } from '../utils.svelte' + import RunForm from '$lib/components/RunForm.svelte' + import { goto } from '$lib/navigation' import { deepEqual } from 'fast-equals' import type { Flow, FlowModule, InputTransform, Job, OpenFlow } from '$lib/gen' import { emptySchema, type StateStore } from '$lib/utils' @@ -355,6 +357,26 @@ ) } + let runLoading = $state(false) + let scheduledForStr = $state(undefined) + let invisible_to_owner = $state(undefined) + let overrideTag = $state(undefined) + + /** One run from the form, as the same one-step flow a chat turn runs but outside any + * conversation, then onto its run page. */ + async function runOnce(_scheduledFor: string | undefined, args: Record) { + if (!agentModule) return + runLoading = true + try { + const flow = agentChatFlow($state.snapshot(agentModule) as FlowModule) + flow.value.chat_input_enabled = false + const id = await runFlowPreview(args, flow, path, undefined, undefined, undefined, workspace) + await goto(`/run/${id}?workspace=${workspace}`) + } finally { + runLoading = false + } + } + // What the composer reads for its model button and paperclip. let chatModules = $derived(agentModule ? agentChatFlow(agentModule).value.modules : undefined) let chatGap = $derived(agentChatGap(agentValue?.input_transforms)) @@ -446,18 +468,17 @@
- - - - {#if view} - {@render runPane()} - {@render configPane()} - {:else} + {#if view} + + {@render runPane()} + {:else} + + {@render configPane()} {@render runPane()} - {/if} - + + {/if}
{#snippet configPane()} @@ -521,36 +542,54 @@
-
- - -
- +
+ +
+ {:else} +
+ + +
+ +
+
+ + -
- - - - - -
+
+
+
+ {/if} {#if chatMounted}
{#if chatGap?.memory} diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index 05487b0333..ab516ea267 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -1,6 +1,5 @@