feat: chat with a saved agent from the agent editor (#11292)

* feat: chat with a saved agent from the agent editor

* fix: keep agent chats apart from a same-path flow's conversations

* fix: point an agent editor chat's model gap at the agent, not a step

* fix: match the memory gate's icon to the chat's empty state
This commit is contained in:
hugocasa
2026-09-22 17:07:43 +00:00
committed by GitHub
parent 89323fb025
commit d8aaa73573
7 changed files with 406 additions and 46 deletions
@@ -1,7 +1,16 @@
<script lang="ts">
import { getContext, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import { Alert } from '$lib/components/common'
import { Info, MessageCircleOff } from 'lucide-svelte'
import { Alert, Button } from '$lib/components/common'
import FlowChat from '../conversations/FlowChat.svelte'
import {
AGENT_CHAT_SCHEMA,
agentChatFlow,
agentChatGap,
agentChatPath
} from '../conversations/agentEditorChat'
import { runFlowPreview } from '../utils.svelte'
import { deepEqual } from 'fast-equals'
import type { Flow, FlowModule, InputTransform, Job, OpenFlow } from '$lib/gen'
import { emptySchema, type StateStore } from '$lib/utils'
@@ -30,7 +39,12 @@
type AIAgentConfig
} from '../agentResourceUtils'
import { agentArgsToTransforms } from '../linkedAgentDrafts'
import { AGENT_EDITOR_RUN_INPUTS, AGENT_TOOLS_ROW } from '../agentFormFields'
import {
AGENT_EDITOR_RUN_INPUTS,
AGENT_TOOLS_ROW,
DEFAULT_AGENT_MEMORY,
keepsManagedMemory
} from '../agentFormFields'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import { useAgentDraft } from '../agentDraft.svelte'
import Path from '$lib/components/Path.svelte'
@@ -199,6 +213,17 @@
// apart from the echo of their own write.
let lastArgs = $state<string | undefined>(undefined)
// Picked on the first load, after which it is the reader's: following the memory setting live
// would pull them out of a chat for switching memory off in the form.
let testMode = $state<'form' | 'chat' | undefined>(undefined)
// Mounted the first time it is shown, then only hidden: unmounting destroys the SDK chat,
// which stops following the turn in flight, and its answer would never show.
let chatMounted = $state(false)
function showTestMode(mode: 'form' | 'chat') {
testMode = mode
if (mode === 'chat') chatMounted = true
}
// draft.args -> module. Only when the draft moved on its own (a load, an external write, a
// discard); an edit made in the form arrives here as its own echo and is skipped.
$effect(() => {
@@ -217,6 +242,7 @@
} as any
}
flowStore.val.value.modules = [built]
if (testMode === undefined) showTestMode(keepsManagedMemory(args.memory) ? 'chat' : 'form')
// Caught rather than left to float: a tool whose shape the schema loader cannot read
// would otherwise reject into the global unhandled-rejection handler, which reports the
// bare message and no stack — saying nothing about which agent or tool caused it. The
@@ -294,6 +320,43 @@
let testIsLoading = $state(false)
let scriptProgress = $state(undefined)
let chatPath = $derived(agentChatPath(path))
/** One chat turn: the agent as edited, not as deployed, the same as a run from the form. */
async function runChatTurn(
userMessage: string,
conversationId: string,
inputs?: Record<string, any>
): Promise<string | undefined> {
if (!agentModule) return undefined
return await runFlowPreview(
{ ...(inputs ?? {}), user_message: userMessage },
agentChatFlow($state.snapshot(agentModule) as FlowModule),
chatPath,
undefined,
conversationId,
undefined,
workspace
)
}
// 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))
function turnOnMemory() {
if (!agentValue) return
agentValue.input_transforms.memory = {
type: 'static',
value: structuredClone(DEFAULT_AGENT_MEMORY)
}
}
function turnOnStreaming() {
if (!agentValue) return
agentValue.input_transforms.streaming = { type: 'static', value: true }
}
// Adding a tool goes straight into it: the editor has no graph to show the new node on, so the
// tool it just created is the only place the click can land.
async function addTool(detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) {
@@ -341,6 +404,18 @@
export function draftHandle() {
return draft
}
/** The switch between the form and the chat, which the dialog's header holds. `mode` is
* undefined until the agent loads and picks the first one. */
export function testPaneHandle() {
return {
get mode() {
return testMode
},
set mode(mode: 'form' | 'chat' | undefined) {
if (mode) showTestMode(mode)
}
}
}
</script>
{#if draft.refusal}
@@ -359,7 +434,7 @@
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
answer want opposite splits, and only the reader knows which they are on. -->
<Splitpanes class="h-full">
<Pane size={66} minSize={30}>
<Pane size={55} minSize={30}>
<div class="h-full min-h-0 overflow-auto">
<div class="px-4 pt-4">
<Label label="Path">
@@ -410,37 +485,98 @@
</PropPickerWrapper>
</div>
</Pane>
<!-- Laid out as the script editor's preview column is: what a run takes above what it
produced, both alongside what is being edited. -->
<Pane size={34} minSize={20}>
<Splitpanes horizontal class="h-full">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
/>
<Pane size={45} minSize={20}>
<div class="h-full min-h-0 flex flex-col">
<!-- Laid out as the script editor's preview column is: what a run takes above what it
produced, both alongside what is being edited. -->
<div class="flex-1 min-h-0 {testMode === 'chat' ? 'hidden' : ''}">
<Splitpanes horizontal class="h-full">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
/>
</div>
</Pane>
<Pane size={60} minSize={20}>
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</Pane>
</Splitpanes>
</div>
{#if chatMounted}
<div class={testMode === 'chat' ? 'flex flex-col flex-1 min-h-0' : 'hidden'}>
{#if chatGap?.memory}
<div
class="flex-1 flex flex-col items-center justify-center gap-2 px-8 text-center"
>
<MessageCircleOff size={48} class="text-tertiary opacity-50 mb-2" />
<p class="text-sm font-semibold text-emphasis">Chat needs managed memory</p>
<p class="text-xs text-secondary max-w-xs">
{chatGap.memoryCanTurnOn
? 'Without it, every message would be answered without the ones before it.'
: 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'}
</p>
{#if chatGap.memoryCanTurnOn && !readOnly}
<Button
unifiedSize="sm"
variant="default"
btnClasses="bg-surface mt-2"
onClick={turnOnMemory}
>
Turn on managed memory
</Button>
{/if}
</div>
{:else if chatGap?.noStream}
<div
class="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b text-2xs text-secondary"
>
<Info size={12} class="shrink-0" />
<span class="flex-1">
{chatGap.noStream === 'image'
? 'Image answers do not stream: each one shows once its run ends.'
: 'Streaming is off: each answer shows once its run ends.'}
</span>
{#if chatGap.noStream === 'off' && !readOnly}
<Button unifiedSize="2xs" variant="subtle" onClick={turnOnStreaming}>
Turn on
</Button>
{/if}
</div>
{/if}
<!-- Hidden rather than unmounted while memory is off: switching it off mid-turn must
not end the chat following that turn. Test chats, since what runs is the agent as
edited. -->
<div class={chatGap?.memory ? 'hidden' : 'flex flex-col flex-1 min-h-0'}>
<FlowChat
onRunFlow={runChatTurn}
path={chatPath}
conversationKind="test"
subject="agent"
frame="none"
inputSchema={AGENT_CHAT_SCHEMA}
flowModules={chatModules}
/>
</div>
</div>
</Pane>
<Pane size={60} minSize={20}>
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</Pane>
</Splitpanes>
{/if}
</div>
</Pane>
</Splitpanes>
</div>
@@ -1,5 +1,7 @@
<script lang="ts">
import { FlaskConical, History, Save } from 'lucide-svelte'
import { FlaskConical, FormInput, History, MessageSquare, Save } from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { onDestroy, untrack } from 'svelte'
import { resource } from 'runed'
import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte'
@@ -93,6 +95,7 @@
})
let draft = $derived(host?.draftHandle())
let testPane = $derived(host?.testPaneHandle())
// The load refused this path, so there is no agent to deploy, evaluate or show a history of:
// the dialog carries only the refusal the host renders.
let refused = $derived(draft?.refusal != null)
@@ -268,6 +271,38 @@
{#snippet settings()}
<div class="flex flex-row items-center gap-2 shrink-0">
{#if !inEvals && !refused}
<!-- Switches the editor's right-hand pane, and is drawn only once the agent has
loaded and the pane has picked its first mode. -->
{#if testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="sm"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="sm"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{#if readOnly}
<Badge
color="gray"
@@ -49,6 +49,9 @@
* users have no test chats to look at.
*/
conversationKind?: 'test' | 'deployed'
/** What a message runs, as the chat names it. An agent has no deployed chats, so its
* sidebar offers no filter between those and the test ones. */
subject?: 'flow' | 'agent'
}
let {
@@ -62,7 +65,8 @@
description = undefined,
wideLayout = false,
frame = 'top',
conversationKind = 'deployed'
conversationKind = 'deployed',
subject = 'flow'
}: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
@@ -87,13 +91,13 @@
pollDelayMs: $enterpriseLicense ? 50 : undefined,
run: async ({ user_message, ...inputs }, { conversationId }) => {
const jobId = await onRunFlow(String(user_message), conversationId, inputs)
if (!jobId) throw new Error('the flow did not start')
if (!jobId) throw new Error(`the ${subject} did not start`)
// The server creates the conversation with the run, so the sidebar can list
// it now, whatever becomes of the turn.
sidebar?.conversationStarted(conversationId)
return jobId
},
onError: (error) => sendUserToast('Failed to run flow: ' + error.message, true)
onError: (error) => sendUserToast(`Failed to run ${subject}: ${error.message}`, true)
})
const unsubscribe = created.subscribe((s) => (chatState = s))
chat = created
@@ -126,7 +130,7 @@
{chat}
{chatState}
defaultKind={conversationKind}
canFilterKind={conversationKind !== 'deployed'}
canFilterKind={conversationKind !== 'deployed' && subject === 'flow'}
/>
{/if}
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
@@ -147,6 +151,7 @@
{description}
{wideLayout}
{conversationKind}
{subject}
/>
{/key}
</div>
@@ -44,6 +44,8 @@
wideLayout?: boolean
/** What this surface's runs create: previews in the editor, deployed runs on the flow page. */
conversationKind?: 'test' | 'deployed'
/** What a message runs, as the composer names it. */
subject?: 'flow' | 'agent'
}
let {
@@ -56,7 +58,8 @@
workspace = undefined,
description = undefined,
wideLayout = false,
conversationKind = 'deployed'
conversationKind = 'deployed',
subject = 'flow'
}: Props = $props()
// Derive helperScript for dynamic inputs from schema
@@ -91,7 +94,7 @@
const modelWiring = $derived(resolveAgentModelWiring(flowModules))
// An agent with nothing to call cannot answer, and the composer cannot fix it, so the
// chat says what to go and do instead of offering controls that write nowhere.
const modelGap = $derived(agentModelGap(modelWiring))
const modelGap = $derived(agentModelGap(modelWiring, subject))
const showModelButton = $derived(showsModelButton(modelWiring))
// LocalStorage helpers
@@ -273,7 +276,7 @@
{:else}
<MessageCircle size={48} class="mx-auto mb-4 opacity-50" />
<p class="text-lg font-medium">Start a conversation</p>
<p class="text-sm">Send a message to run the flow and see the results</p>
<p class="text-sm">Send a message to run the {subject} and see the results</p>
{#if !emptyString(description)}
<div class="mt-6 pt-4 border-t max-w-md text-left text-xs text-tertiary">
<GfmMarkdown md={description ?? ''} noPadding prose="sm" />
@@ -336,7 +339,7 @@
{wideLayout}
{emptyHint}
footerSettings={modalSchema || showModelButton ? footerSettings : undefined}
placeholder="Send a message to run the flow"
placeholder="Send a message to run the {subject}"
disabled={deploymentInProgress || !!modelGap || !!wrongKindReason}
disabledMessage={deploymentInProgress
? 'Deployment in progress'
@@ -242,15 +242,19 @@ export function resolveAgentModelWiring(
* about it, because no flow input feeds either field. Saying so beats a dead model button.
* A field the flow exposes is never a gap: the reader picks it in the composer.
*/
export function agentModelGap(wiring: AgentModelWiring | undefined): string | undefined {
export function agentModelGap(
wiring: AgentModelWiring | undefined,
subject: 'flow' | 'agent' = 'flow'
): string | undefined {
// No agent, several of them, or an expression we cannot read: not ours to judge.
if (!wiring || wiring.whole) return undefined
// Asked of each agent rather than of what they agree on: agents that merely disagree
// about the model all have one, and the message would be false — while an agent with
// an empty model still cannot run, however well the others are configured.
return wiring.someAgentCannotRun
? 'Pick a provider and model on the AI agent step to use this chat.'
: undefined
if (!wiring.someAgentCannotRun) return undefined
return subject === 'agent'
? 'Pick a provider and model for this agent to use this chat.'
: 'Pick a provider and model on the AI agent step to use this chat.'
}
/**
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { FlowModule } from '$lib/gen'
import { agentChatFlow, agentChatGap, agentChatPath } from './agentEditorChat'
describe('agentChatPath', () => {
it('is a path no flow can take, so a same-path flow never shares its conversations', () => {
const flowPath = /^[ufg](\/[\w-]+){2,}$/
expect('f/support/agent').toMatch(flowPath)
expect(agentChatPath('f/support/agent')).not.toMatch(flowPath)
})
})
function agent(inputTransforms: Record<string, any>): FlowModule {
return {
id: '__wm_agent_root',
value: { type: 'aiagent', tools: [{ id: 't' }], input_transforms: inputTransforms }
} as unknown as FlowModule
}
describe('agentChatFlow', () => {
it('runs the agent in chat mode on the message and files, never on a step memory id', () => {
const flow = agentChatFlow(
agent({
system_prompt: { type: 'static', value: 'Be brief' },
user_message: { type: 'static', value: 'a default' },
memory_id: { type: 'static' },
previous_messages: { type: 'static' }
})
)
expect(flow.value.chat_input_enabled).toBe(true)
const step = flow.value.modules[0].value as any
expect(step.tools).toEqual([{ id: 't' }])
expect(step.input_transforms).toEqual({
system_prompt: { type: 'static', value: 'Be brief' },
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
user_attachments: { type: 'javascript', expr: 'flow_input.user_attachments' }
})
})
})
describe('agentChatGap', () => {
it('is nothing to fix with managed memory and streaming left at its default', () => {
expect(
agentChatGap({ memory: { type: 'static', value: { kind: 'window', context_length: 10 } } })
).toBeUndefined()
})
it('reads an agent with no memory setting as off, and offers to turn it on', () => {
expect(agentChatGap({})).toEqual({ memory: true, memoryCanTurnOn: true, noStream: undefined })
})
it('flags memory off and streaming off', () => {
expect(
agentChatGap({
memory: { type: 'static', value: { kind: 'off' } },
streaming: { type: 'static', value: false }
})
).toEqual({ memory: true, memoryCanTurnOn: true, noStream: 'off' })
})
it('does not offer to replace a legacy manual memory', () => {
expect(
agentChatGap({ memory: { type: 'static', value: { kind: 'manual', messages: [] } } })
).toEqual({ memory: true, memoryCanTurnOn: false, noStream: undefined })
})
it('flags an image answer as never streaming, whatever the setting', () => {
expect(
agentChatGap({
memory: { type: 'static', value: { kind: 'window', context_length: 10 } },
output_type: { type: 'static', value: 'image' },
streaming: { type: 'static', value: true }
})
).toEqual({ memory: false, memoryCanTurnOn: false, noStream: 'image' })
})
})
@@ -0,0 +1,101 @@
import type { AiAgent, FlowModule, InputTransform, OpenFlow } from '$lib/gen'
import { AGENT_FLOW_LOCAL_KEYS } from '../agentResourceUtils'
import { agentMemoryMode, keepsManagedMemory } from '../agentFormFields'
/**
* What an agent's chat runs, and files its conversations, under. Conversations are keyed by path
* alone, and a flow may share the agent's, so the suffix is one no flow path can carry (its
* segments are `[\w-]`): otherwise each would list the other's chats, and continuing one would run
* a step that never saw its memory. The first two segments, which the server checks a preview
* run's path against, stay the agent's.
*/
export function agentChatPath(agentPath: string): string {
return `${agentPath}.chat`
}
/**
* 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.
*/
export const AGENT_CHAT_SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
user_message: { type: 'string', description: 'Message from user' },
user_attachments: {
type: 'array',
items: { type: 'object', resourceType: 's3object' },
description: 'Images or PDFs for the agent to read'
}
},
required: ['user_message']
}
/**
* The chat-mode flow one turn of the agent editor's chat runs: the agent as edited, alone, reading
* the message and files from the chat. Every other flow-local input is dropped, `memory_id` above
* all: a step memory id, even an empty one, would replace the conversation's and the agent would
* not remember the turns before.
*/
export function agentChatFlow(agent: FlowModule): OpenFlow {
const value = agent.value as AiAgent
const brain = Object.fromEntries(
Object.entries(value.input_transforms ?? {}).filter(
([key]) => !(AGENT_FLOW_LOCAL_KEYS as readonly string[]).includes(key)
)
)
return {
summary: '',
value: {
chat_input_enabled: true,
modules: [
{
id: agent.id,
value: {
...value,
input_transforms: {
...brain,
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
user_attachments: { type: 'javascript', expr: 'flow_input.user_attachments' }
} as AiAgent['input_transforms']
}
}
]
},
schema: AGENT_CHAT_SCHEMA
}
}
/** What the agent as configured takes away from a chat. */
export type AgentChatGap = {
/** No managed memory: every message would be answered without the ones before it, so the
* chat is not offered at all. */
memory: boolean
/** Whether turning memory on is a plain switch, rather than replacing an older manual list. */
memoryCanTurnOn: boolean
/** Why a turn shows nothing until its run ends: streaming switched off, or an image answer,
* which the worker never streams whatever the setting. */
noStream: 'off' | 'image' | undefined
}
function staticValue(transform: InputTransform | undefined): unknown {
return transform?.type === 'static' ? transform.value : undefined
}
export function agentChatGap(
transforms: Record<string, InputTransform> | undefined
): AgentChatGap | undefined {
const memory = staticValue(transforms?.['memory'])
const gap: AgentChatGap = {
memory: !keepsManagedMemory(memory),
memoryCanTurnOn: agentMemoryMode(memory) === 'off',
noStream:
staticValue(transforms?.['output_type']) === 'image'
? 'image'
: // Unset streams: it is on by default.
staticValue(transforms?.['streaming']) === false
? 'off'
: undefined
}
return gap.memory || gap.noStream ? gap : undefined
}