mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-29 08:06:26 +00:00
* feat: configurable workspace roles with per-workspace overrides Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: close role review gaps in pickers, declarative sync and removal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: keep resolve_role_name doc on its function Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: validate overrides on instance role changes and bill admins fully Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: allow pipeline drafts and keep role checks on one connection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: list data pipelines in operator_can_draft Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: expect a 403 role refusal when an operator resolves jobs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: require a valid license for custom workspace roles Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: freeze workspace roles when the license lapses Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: pin the ee companion for workspace roles Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: broadcast role override changes from the write path, not a trigger Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to b5294d38db082bdd104d5358a47695390694c05d This commit updates the EE repository reference after PR #891 was merged in windmill-ee-private. Previous ee-repo-ref: 688e00c5982865b14098f530d54280e2311994c0 New ee-repo-ref: b5294d38db082bdd104d5358a47695390694c05d Automated by sync-ee-ref workflow. * feat: add workspace roles settings UI and role pickers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show the server's reason when a settings save fails Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: default role pickers to a defined role and confirm resets of unknown capabilities Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: treat null role capabilities as unset and key role drafts by map Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: gate the frontend on workspace role capabilities Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: gate deploys, bulk actions and chat composition per role kind Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: treat a superadmin as full in deploy permission checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: leave page visibility out of isRestricted, as the server does Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let a member discard their own draft without the authoring right Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show custom roles as inactive without a valid license Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the user store import on the agent page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show custom roles as frozen without a valid license Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: reorder instance roles with a drag handle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let an admin row that kept the operator flag hold full rights everywhere Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: treat superadmin members as admins and return role names from get user Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: move the instance role label into the expanded panel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
788 lines
30 KiB
Svelte
788 lines
30 KiB
Svelte
<script lang="ts">
|
|
import ResourceDescriptionField from '$lib/components/ResourceDescriptionField.svelte'
|
|
import { Button, Drawer, DrawerContent } from '$lib/components/common'
|
|
import Alert from '$lib/components/common/alert/Alert.svelte'
|
|
import Badge from '$lib/components/common/badge/Badge.svelte'
|
|
import Path from '$lib/components/Path.svelte'
|
|
import Label from '$lib/components/Label.svelte'
|
|
import ResourcePathHint from '$lib/components/ResourcePathHint.svelte'
|
|
import { ResourceService, type InputTransform, type Resource } from '$lib/gen'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
|
import { canEditItem } from '$lib/editRights'
|
|
import { capabilitiesIn } from '$lib/roleCapabilities'
|
|
import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte'
|
|
import {
|
|
AGENT_BRAIN_KEYS,
|
|
AGENT_FLOW_LOCAL_KEYS,
|
|
agentConfigToInputTransforms,
|
|
flowLocalInputs,
|
|
inputTransformsToAgentConfig,
|
|
nonStaticBrainKeys,
|
|
providerWithOverrides,
|
|
summarizeAgentBrain,
|
|
type AIAgentConfig,
|
|
type AgentTool
|
|
} from '../agentResourceUtils'
|
|
import {
|
|
agentDraftSaveCount,
|
|
agentWriteCount,
|
|
markAgentWritten,
|
|
openAgentEditor
|
|
} from '../agentEditorStore.svelte'
|
|
import {
|
|
setLinkedAgentTools,
|
|
clearLinkedAgentTools,
|
|
linkedModulesForAgent,
|
|
linkedToolsScope
|
|
} from '../linkedAgentToolsStore.svelte'
|
|
import { logReusableAgentUsage } from '../agentTelemetry'
|
|
import { claimLinkedToolsFetch } from '../flowState'
|
|
import {
|
|
AgentDraftUnavailable,
|
|
fetchAgentWithDraft,
|
|
isExpectedLinkFailure
|
|
} from '../linkedAgentDrafts'
|
|
import type { AgentResourceState } from '../agentDraft.svelte'
|
|
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
|
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
|
import type { AgentTool as AgentToolStrict } from '../agentToolUtils'
|
|
import { resource } from 'runed'
|
|
import { untrack } from 'svelte'
|
|
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
|
|
let {
|
|
agent = $bindable(),
|
|
inputTransforms = $bindable(),
|
|
tools = $bindable(),
|
|
toolInputs = $bindable(),
|
|
moduleId,
|
|
opWorkspace = undefined,
|
|
flowPath = '',
|
|
fromAgentEditor = false,
|
|
chatInputEnabled = false,
|
|
linkedAgent = $bindable()
|
|
}: {
|
|
agent: string | undefined
|
|
inputTransforms: Record<string, InputTransform>
|
|
tools: AgentTool[]
|
|
toolInputs: Record<string, Record<string, InputTransform>>
|
|
moduleId: string
|
|
// The workspace the flow editor operates on (differs from the nav workspace in session/fork
|
|
// editors). All resource reads/writes must target it, not the navigation workspace.
|
|
opWorkspace?: string
|
|
// Scope for the linked-agent tools store (the flow path); must match what the graph reads.
|
|
flowPath?: string
|
|
// Inside the agent editor, where an agent used as a tool stays part of the agent being
|
|
// edited: it cannot be saved as a reusable agent of its own, and one already linked cannot be
|
|
// opened here. Linking a saved agent to another agent is out of scope for this editor — the
|
|
// backend supports it, but only a flow can author it, and a second editor over a second draft
|
|
// is the wrong way in.
|
|
fromAgentEditor?: boolean
|
|
chatInputEnabled?: boolean
|
|
// The linked agent's memory and provider once its config has loaded, for the step's history
|
|
// inputs and its overrides.
|
|
linkedAgent?: { memory: unknown; provider?: unknown } | undefined
|
|
} = $props()
|
|
|
|
let ws = $derived(opWorkspace ?? $operatingWorkspace)
|
|
|
|
// How many times the linked agent has been written, from anywhere: this card's own save, or a
|
|
// deploy from the agent editor mounted alongside it. Both reads below key on it, so neither
|
|
// keeps naming the config and version a write has just replaced.
|
|
let writes = $derived(agentWriteCount(ws, agent))
|
|
// Draft saves as well, for the link fetch: the card shows what a test of this step would run,
|
|
// and that is the draft. Only the deploy moves `writes`, so without this the card would keep
|
|
// describing the config the agent held before it was edited.
|
|
let draftSaves = $derived(agentDraftSaveCount(ws, agent))
|
|
|
|
let saveDrawer: Drawer | undefined = $state()
|
|
let newPath = $state('')
|
|
let pathError = $state('')
|
|
let description = $state('')
|
|
let saving = $state(false)
|
|
|
|
type LinkedInfo = {
|
|
// What this result was fetched for. runed's resource neither aborts nor tags a superseded
|
|
// request, so a slow fetch can land after a newer one: every consumer gates on these matching
|
|
// the current (ws, agent, writes, draftSaves). `writes` is what covers a refetch of the *same*
|
|
// link after a deploy — without it a pre-deploy response is indistinguishable from the current
|
|
// one, and accepting it republishes the tools the deploy just replaced. `draftSaves` does the
|
|
// same for a draft save, which the card follows just as closely.
|
|
ws?: string
|
|
path?: string
|
|
writes: number
|
|
draftSaves: number
|
|
config: AIAgentConfig
|
|
tools: AgentTool[]
|
|
/** The config shown came from the agent's unsaved draft rather than the deployed resource. */
|
|
fromDraft: boolean
|
|
providerPath?: string
|
|
providerOk: boolean
|
|
/** The agent's own sharing, to tell whether this user may edit it. */
|
|
extraPerms?: Record<string, boolean>
|
|
/** The link cannot be read. `missing` (404): nothing exists at the path, the agent having been
|
|
* renamed or deleted. `forbidden` (401/403): it exists and this user is refused it, a folder
|
|
* they cannot read included, which says nothing about whether a run of the flow can read it.
|
|
* Returned rather than thrown so it is guarded like any result. */
|
|
unavailable?: 'missing' | 'forbidden'
|
|
}
|
|
|
|
async function fetchLinkedAgent(
|
|
path: string,
|
|
ws: string
|
|
): Promise<{ response: Resource; draft: AgentResourceState | undefined }> {
|
|
try {
|
|
return await fetchAgentWithDraft(path, ws)
|
|
} catch (err) {
|
|
// Only the DRAFT was unreadable. This card is a display, so fall back to the deployed
|
|
// agent rather than rendering one with no brain and no tools, which reads as "the agent
|
|
// is empty" while the Draft badge still says it has unsaved changes. Same fallback the
|
|
// graph's tool nodes take; the paths that run or deploy the draft still refuse.
|
|
if (!(err instanceof AgentDraftUnavailable)) throw err
|
|
return {
|
|
response: await ResourceService.getResource({ workspace: ws, path }),
|
|
draft: undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
// A linked agent is rigid and read-only: its brain and tools come from the resource. We
|
|
// load them here for display, and probe the provider resource so we can warn when it isn't
|
|
// accessible in this workspace (the user then needs to unlink/fork or gain access).
|
|
// The draft when there is one, since that is what a test of this step runs.
|
|
let linkedResource = resource(
|
|
() => ({ ws, path: agent, writes, draftSaves }),
|
|
async ({ ws, path, writes, draftSaves }): Promise<LinkedInfo> => {
|
|
const empty = {
|
|
ws,
|
|
path,
|
|
writes,
|
|
draftSaves,
|
|
config: {},
|
|
tools: [],
|
|
fromDraft: false,
|
|
providerOk: true
|
|
}
|
|
if (!ws || !path) {
|
|
return empty
|
|
}
|
|
let response: Resource
|
|
let draft: AgentResourceState | undefined
|
|
try {
|
|
;({ response, draft } = await fetchLinkedAgent(path, ws))
|
|
} catch (err) {
|
|
if (!isExpectedLinkFailure(err)) throw err
|
|
const status = (err as { status?: number }).status
|
|
return { ...empty, unavailable: status === 404 ? 'missing' : 'forbidden' }
|
|
}
|
|
const cfg = (draft?.args ?? response.value ?? {}) as AIAgentConfig & {
|
|
provider?: { resource?: string }
|
|
}
|
|
const tools = (cfg.tools ?? []) as AgentTool[]
|
|
const providerRef = cfg.provider?.resource
|
|
const providerPath =
|
|
typeof providerRef === 'string' && providerRef
|
|
? providerRef.replace(/^\$res:/, '').replace(/^res:\/\//, '')
|
|
: undefined
|
|
let providerOk = true
|
|
if (providerPath) {
|
|
try {
|
|
await ResourceService.getResource({ workspace: ws, path: providerPath })
|
|
} catch {
|
|
providerOk = false
|
|
}
|
|
}
|
|
return {
|
|
ws,
|
|
path,
|
|
writes,
|
|
draftSaves,
|
|
config: cfg,
|
|
tools,
|
|
fromDraft: draft != undefined,
|
|
providerPath,
|
|
providerOk,
|
|
extraPerms: (response.extra_perms ?? {}) as Record<string, boolean>
|
|
}
|
|
}
|
|
)
|
|
// Retain the last result that matched the current link. Discarding a superseded one outright
|
|
// would blank the card, because a stale response for a previous agent replaces
|
|
// `linkedResource.current` and nothing refetches the one actually linked.
|
|
let loadedInfo = $state<LinkedInfo | undefined>(undefined)
|
|
$effect(() => {
|
|
const current = linkedResource.current
|
|
if (
|
|
current &&
|
|
current.ws === ws &&
|
|
current.path === agent &&
|
|
current.writes === writes &&
|
|
current.draftSaves === draftSaves
|
|
) {
|
|
loadedInfo = current
|
|
}
|
|
})
|
|
let linkedInfo = $derived(
|
|
loadedInfo?.ws === ws && loadedInfo?.path === agent ? loadedInfo : undefined
|
|
)
|
|
$effect(() => {
|
|
linkedAgent = linkedInfo
|
|
? { memory: linkedInfo.config?.memory, provider: linkedInfo.config?.provider }
|
|
: undefined
|
|
})
|
|
let inheritedTools = $derived(linkedInfo?.tools ?? [])
|
|
let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config))
|
|
let providerPath = $derived(linkedInfo?.providerPath)
|
|
let providerOk = $derived(linkedInfo?.providerOk ?? true)
|
|
let unavailable = $derived(linkedInfo?.unavailable ?? false)
|
|
let canEditAgent = $derived(
|
|
!!agent &&
|
|
canEditItem(
|
|
'agent',
|
|
agent,
|
|
linkedInfo?.extraPerms,
|
|
$userStore ?? undefined,
|
|
capabilitiesIn(
|
|
ws === $workspaceStore ? $userStore : undefined,
|
|
$userWorkspaces.find((w) => w.id === ws)
|
|
)
|
|
)
|
|
)
|
|
// The hint flips on the first keystroke in the agent editor, so the badge does not wait for the
|
|
// debounced autosave and the refetch behind it; the fetched answer covers a draft written
|
|
// elsewhere, which no editor here has published an opinion about.
|
|
let hasDraft = $derived(
|
|
getLocalDraftHint(ws, 'resource', agent ?? '') ?? linkedInfo?.fromDraft ?? false
|
|
)
|
|
/** The agent the card is about: the one this step links to, or the one being edited. */
|
|
let cardPath = $derived(agent)
|
|
// The version eval runs are recorded against. The resource does not hold it; its newest history
|
|
// entry does, since recording is a database trigger on every write.
|
|
let versionResource = resource(
|
|
() => ({ ws, path: cardPath, writes }),
|
|
async ({
|
|
ws,
|
|
path,
|
|
writes
|
|
}): Promise<{ ws?: string; path?: string; writes: number; version?: number }> => {
|
|
if (!ws || !path) {
|
|
return { ws, path, writes }
|
|
}
|
|
const history = await ResourceService.getResourceHistory({ workspace: ws, path })
|
|
return { ws, path, writes, version: history.versions?.[0]?.version }
|
|
}
|
|
)
|
|
// Guarded like the link above, `writes` included: a response for a previous agent must not label
|
|
// this one, and one from before a deploy must not relabel it with the version it replaced.
|
|
let version = $derived.by(() => {
|
|
const loaded = versionResource.current
|
|
return loaded !== undefined &&
|
|
loaded.ws === ws &&
|
|
loaded.path === cardPath &&
|
|
loaded.writes === writes
|
|
? loaded.version
|
|
: undefined
|
|
})
|
|
|
|
// Keep the graph's linked-tool store current for this step. flowState resolves every linked step
|
|
// at load; here we refresh the one being edited when its link changes (or clear it on unlink), so
|
|
// its tool nodes update without reloading the flow.
|
|
let toolScope = $derived(linkedToolsScope(ws, flowPath))
|
|
// The agent the store's tools currently belong to, so a link change can be told apart from a step
|
|
// whose tools were resolved at flow load. Seeded from the link at mount, because initFlowState
|
|
// has already published for it — leaving it unset would miss a link change that lands before this
|
|
// component's own request. Deliberately not reactive: it tracks what was written.
|
|
let publishedFor: string | undefined = untrack(() => agent)
|
|
$effect(() => {
|
|
if (!agent) {
|
|
// Claim first: an in-flight fetch for the previous link would otherwise still pass its own
|
|
// generation check and re-add the tools we just cleared.
|
|
claimLinkedToolsFetch(toolScope, moduleId)
|
|
clearLinkedAgentTools(toolScope, moduleId)
|
|
publishedFor = undefined
|
|
return
|
|
}
|
|
const loaded = linkedInfo
|
|
if (loaded) {
|
|
// Every step of this flow linking this agent, not just this one. Tools belong to the agent,
|
|
// so the sibling steps show the same set, and only the selected step mounts this card:
|
|
// without them a draft saved from here leaves their nodes on what the flow load resolved,
|
|
// while a test of those steps runs the draft. Claimed like this card's own publish, so a
|
|
// sibling's in-flight fetch cannot land afterwards and put the old tools back.
|
|
const modules = new Set(linkedModulesForAgent(toolScope, agent))
|
|
modules.add(moduleId)
|
|
for (const id of modules) {
|
|
claimLinkedToolsFetch(toolScope, id)
|
|
// linkedResource types tools loosely; they are the same resource tools the store holds.
|
|
setLinkedAgentTools(toolScope, id, loaded.tools as AgentToolStrict[], agent)
|
|
}
|
|
publishedFor = agent
|
|
} else if (publishedFor !== undefined && publishedFor !== agent) {
|
|
// The link moved and the new agent hasn't resolved, so the stored tools are the old one's.
|
|
// No claim: writing `agent` re-runs the editor's watcher, which already superseded the old
|
|
// fetch and started one for the new link — claiming would discard it, leaving the graph
|
|
// empty unless this step stays selected. Load-time tools stay put (publishedFor unset).
|
|
clearLinkedAgentTools(toolScope, moduleId)
|
|
publishedFor = undefined
|
|
}
|
|
})
|
|
|
|
function toolLabel(tool: AgentTool): string {
|
|
return tool.summary || tool.value?.tool_type || tool.id
|
|
}
|
|
|
|
let showDetail = $state(false)
|
|
|
|
function openSave() {
|
|
newPath = ''
|
|
pathError = ''
|
|
description = ''
|
|
saveDrawer?.openDrawer()
|
|
}
|
|
|
|
// The provider is required by the backend (AIAgentArgs.provider is non-optional), so an agent
|
|
// saved without a complete one fails on every linked run. Block saving when the provider is
|
|
// computed/connected (only a static value can be captured into the resource) or when the static
|
|
// value is incomplete (a fresh step defaults to empty resource/model, which is still static).
|
|
// A saved agent never carries a memory id, so saving would drop the id this step's runs still fall
|
|
// back to and leave them without memory. The author picks what replaces it first. In chat mode the
|
|
// conversation id always won, so there the id was never read.
|
|
let legacyMemorySaveError = $derived.by(() => {
|
|
const memory = inputTransforms?.memory as
|
|
| { type?: string; value?: { kind?: string; context_length?: number; memory_id?: string } }
|
|
| undefined
|
|
const value = memory?.type === 'static' ? memory.value : undefined
|
|
if (chatInputEnabled || value?.kind !== 'auto' || !value.memory_id || !value.context_length) {
|
|
return undefined
|
|
}
|
|
return "This step still uses a fixed memory id from an earlier version. In Managed memory, choose Keep as memory id or Use the run's memory id, then save it as an agent."
|
|
})
|
|
|
|
let providerSaveError = $derived.by(() => {
|
|
const t = inputTransforms?.provider as
|
|
| { type?: string; value?: { resource?: string; model?: string } }
|
|
| undefined
|
|
if (!t || t.type !== 'static') {
|
|
return 'Set a static provider before saving. A linked agent needs a provider stored on the resource, so a computed/connected value can not be saved.'
|
|
}
|
|
if (!t.value?.resource || !t.value?.model) {
|
|
return 'Select a provider resource and model before saving.'
|
|
}
|
|
return undefined
|
|
})
|
|
|
|
// What linking throws away: the brain transforms (static or not) and the step's own tools. The
|
|
// flow-local inputs survive linking, so a change to those must not block it.
|
|
function discardedOnLinkSnapshot(): string {
|
|
const brain: Record<string, unknown> = {}
|
|
for (const key of AGENT_BRAIN_KEYS) {
|
|
if (inputTransforms?.[key] !== undefined) {
|
|
brain[key] = inputTransforms[key]
|
|
}
|
|
}
|
|
return JSON.stringify([brain, tools])
|
|
}
|
|
|
|
// Create or update the `ai_agent` resource at `path` from the step's current brain + tools, then
|
|
// link the step to it.
|
|
// Returns false when the resource was written but the step was left alone, so callers can skip
|
|
// the success toast that would otherwise bury the explanation.
|
|
async function persist(path: string, description?: string): Promise<boolean> {
|
|
const dropped = nonStaticBrainKeys(inputTransforms)
|
|
if (providerSaveError ?? legacyMemorySaveError) {
|
|
throw new Error(providerSaveError ?? legacyMemorySaveError)
|
|
}
|
|
if (dropped.length > 0) {
|
|
sendUserToast(
|
|
`Note: ${dropped.join(', ')} use a computed/connected value and won't be saved into the agent`,
|
|
true
|
|
)
|
|
}
|
|
// Tool inputs are saved verbatim: the agent carries its tools' default bindings (static, AI or
|
|
// flow expressions) as authored. Host flows override per-step via tool_inputs, never here.
|
|
const value = inputTransformsToAgentConfig(inputTransforms, tools)
|
|
// An id an older editor baked into this step names the flow's memory. The agent is shared by
|
|
// every step linking it, and each of those takes its memory id from its own run.
|
|
if (value.memory && typeof value.memory === 'object' && 'memory_id' in value.memory) {
|
|
const { memory_id: _, ...memory } = value.memory as Record<string, unknown>
|
|
value.memory = memory
|
|
}
|
|
// The editor stays live during the requests below, so remember what linking would discard:
|
|
// every brain transform and the tools. Comparing the saved config instead would miss a
|
|
// non-static brain edit, which the resource cannot hold yet linking still strips.
|
|
const savedSnapshot = discardedOnLinkSnapshot()
|
|
// If the step is replaced while the requests below are in flight (undo, a session-draft sync),
|
|
// the resource is still written but the step must not be relinked and emptied. The tools array
|
|
// this save started from is what identifies it, and its link answers for the case where a
|
|
// step keeps that array yet is pointed at an agent of its own meanwhile.
|
|
const forkMarker = tools
|
|
const startedUnlinkedFrom = agent
|
|
const exists = await ResourceService.existsResource({ workspace: ws!, path })
|
|
if (exists) {
|
|
// The drawer's path check is debounced, so a fast save can reach here with an unrelated
|
|
// resource at the path — never clobber a resource of another type.
|
|
const existing = await ResourceService.getResource({ workspace: ws!, path })
|
|
if (existing.resource_type !== 'ai_agent') {
|
|
throw new Error(
|
|
`A ${existing.resource_type} resource already exists at ${path}. Pick another path.`
|
|
)
|
|
}
|
|
await ResourceService.updateResourceValue({
|
|
workspace: ws!,
|
|
path,
|
|
requestBody: { value }
|
|
})
|
|
} else {
|
|
await ResourceService.createResource({
|
|
workspace: ws!,
|
|
requestBody: {
|
|
path,
|
|
value,
|
|
resource_type: 'ai_agent',
|
|
description: description || 'Reusable AI agent'
|
|
}
|
|
})
|
|
}
|
|
// The write minted a version, and nothing else the fetch keys on has to change for it to be
|
|
// the one the card should now be naming.
|
|
markAgentWritten(ws, path)
|
|
if (tools !== forkMarker || agent !== startedUnlinkedFrom) {
|
|
// The resource is written either way; say so, or the drawer just closes with no outcome.
|
|
sendUserToast(
|
|
`Saved ${path}, but the step changed while saving, so it was not linked to the agent`,
|
|
true
|
|
)
|
|
return false
|
|
}
|
|
// Edits made while the save was in flight aren't in the resource; linking now would strip them
|
|
// from the step too, losing them entirely. Keep the step as-is and let the user save again.
|
|
if (discardedOnLinkSnapshot() !== savedSnapshot) {
|
|
sendUserToast(
|
|
`Saved ${path}, but changes made while saving are not in it. Save again to include them`,
|
|
true
|
|
)
|
|
return false
|
|
}
|
|
agent = path
|
|
// The brain + tools now live in the resource; a linked step keeps only the flow-local inputs.
|
|
tools = []
|
|
inputTransforms = flowLocalInputs(inputTransforms)
|
|
return true
|
|
}
|
|
|
|
async function saveAsAgent() {
|
|
if (!ws || pathError || !newPath) {
|
|
return
|
|
}
|
|
saving = true
|
|
try {
|
|
const linked = await persist(newPath, description)
|
|
saveDrawer?.closeDrawer()
|
|
if (linked) {
|
|
logReusableAgentUsage('saved')
|
|
sendUserToast(`Saved reusable agent ${newPath}`)
|
|
}
|
|
} catch (e) {
|
|
sendUserToast(`Failed to save agent: ${e}`, true)
|
|
} finally {
|
|
saving = false
|
|
}
|
|
}
|
|
|
|
// Copy the resource's brain + tools into the step, so it can diverge from the agent it was
|
|
// linked to. This flow's tool_inputs are folded into the tools and then cleared, so the
|
|
// standalone step keeps the bindings it was running with.
|
|
// Returns false when the step changed under the fetch, so the caller can say nothing happened.
|
|
async function forkFromResource(): Promise<boolean> {
|
|
if (!ws || !agent) {
|
|
return false
|
|
}
|
|
const path = agent
|
|
// `tools` is one array per module value, so it identifies the step itself — the path alone
|
|
// would not, since a replacement can carry the same link.
|
|
const stepMarker = tools
|
|
// The draft, like the card above and like a test of this step: forking the deployed value
|
|
// while the card displays a drafted prompt would hand back something the user never saw.
|
|
const { response, draft } = await fetchAgentWithDraft(path, ws)
|
|
// The module may have been replaced while the fetch was in flight (undo, session drafts);
|
|
// applying a stale fork would overwrite the restored state.
|
|
if (agent !== path || tools !== stepMarker) {
|
|
return false
|
|
}
|
|
const cfg = (draft?.args ?? response.value ?? {}) as AIAgentConfig
|
|
// Preserve the flow-local inputs already wired in the step.
|
|
const local: Record<string, InputTransform> = {}
|
|
for (const key of AGENT_FLOW_LOCAL_KEYS) {
|
|
if (inputTransforms?.[key]) {
|
|
local[key] = inputTransforms[key]
|
|
}
|
|
}
|
|
// An unlinked step sets model and effort inside `provider`, so its overrides move there.
|
|
const provider = providerWithOverrides(cfg.provider, inputTransforms)
|
|
const forkedInputs = {
|
|
...agentConfigToInputTransforms(cfg),
|
|
...(provider ? { provider } : {}),
|
|
...local
|
|
}
|
|
const forkedTools = cfg.tools ?? []
|
|
inputTransforms = forkedInputs
|
|
for (const tool of forkedTools) {
|
|
const overrides = toolInputs?.[tool.id]
|
|
if (overrides && tool.value?.input_transforms) {
|
|
tool.value.input_transforms = { ...tool.value.input_transforms, ...overrides }
|
|
}
|
|
}
|
|
toolInputs = {}
|
|
tools = forkedTools
|
|
agent = undefined
|
|
return true
|
|
}
|
|
|
|
// Unlink forks the agent into this step so it can diverge here. It does not write back.
|
|
async function unlink() {
|
|
try {
|
|
const forked = await forkFromResource()
|
|
if (forked) {
|
|
logReusableAgentUsage('unlinked')
|
|
sendUserToast('Forked agent. Its configuration was copied into this step')
|
|
} else {
|
|
sendUserToast('The step changed while loading the agent, so nothing was unlinked', true)
|
|
}
|
|
} catch (e) {
|
|
sendUserToast(`Failed to unlink agent: ${e}`, true)
|
|
}
|
|
}
|
|
|
|
// A link naming nothing readable has nothing to fork. Dropping it leaves a standalone step with its
|
|
// flow-local inputs, to configure here or replace with a saved agent; the tool overrides were
|
|
// keyed by the missing agent's tools, so they go with it.
|
|
function removeLink() {
|
|
toolInputs = {}
|
|
agent = undefined
|
|
sendUserToast('Removed the link to the missing agent')
|
|
}
|
|
|
|
// Edit the saved agent itself. The step stays linked throughout: the edits live in the agent's
|
|
// own resource draft, not in this step, so they survive leaving the flow and are the same edits
|
|
// whichever flow — or the resources page — opened them.
|
|
function editAgent() {
|
|
if (!agent) return
|
|
openAgentEditor({
|
|
path: agent,
|
|
workspace: ws,
|
|
// Where to re-resolve this graph's tool nodes once the agent is deployed.
|
|
host: { flowPath, moduleId }
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<div class="px-2 xl:px-4 py-1.5 border-b border-light">
|
|
{#if agent}
|
|
<div class="rounded-md border border-light bg-surface-tertiary px-3 py-2">
|
|
<!-- The whole line toggles the detail; the link and the buttons stop propagation. -->
|
|
<div
|
|
class="flex items-center gap-2 cursor-pointer"
|
|
role="button"
|
|
tabindex="0"
|
|
aria-expanded={showDetail}
|
|
onclick={() => (showDetail = !showDetail)}
|
|
onkeydown={(e) => {
|
|
// Keys aimed at the buttons inside the row bubble through here; leave them theirs.
|
|
if (e.target !== e.currentTarget) return
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
showDetail = !showDetail
|
|
}
|
|
}}
|
|
>
|
|
<Bot size={16} class="text-primary shrink-0" />
|
|
<div class="min-w-0 flex-1 flex items-center gap-2">
|
|
<a
|
|
class="truncate text-xs font-medium hover:underline"
|
|
href={`/resources?path=${agent}&workspace=${ws}`}
|
|
title={`Open ${agent}`}
|
|
onclick={(e) => e.stopPropagation()}>{agent}</a
|
|
>
|
|
{#if version != undefined}
|
|
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
|
|
v{version}
|
|
</Badge>
|
|
{/if}
|
|
{#if hasDraft}
|
|
<Tooltip class="inline-flex items-center shrink-0">
|
|
<Badge small color="indigo">Draft</Badge>
|
|
{#snippet text()}
|
|
This agent has unsaved changes. Testing this flow runs the draft, and deploying the
|
|
flow offers to deploy it.
|
|
{/snippet}
|
|
</Tooltip>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
{#if brainParams.length > 0 || inheritedTools.length > 0}
|
|
<span class="text-tertiary">
|
|
{#if showDetail}
|
|
<ChevronUp size={14} />
|
|
{:else}
|
|
<ChevronDown size={14} />
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
{#if !fromAgentEditor && !unavailable}
|
|
<Button
|
|
unifiedSize="sm"
|
|
variant="default"
|
|
startIcon={{ icon: Pencil }}
|
|
iconOnly
|
|
title="Edit the saved agent (updates it everywhere it's used)"
|
|
onclick={(e) => {
|
|
e.stopPropagation()
|
|
editAgent()
|
|
}}
|
|
/>
|
|
{/if}
|
|
{#if !unavailable}
|
|
<Button
|
|
unifiedSize="sm"
|
|
variant="default"
|
|
startIcon={{ icon: Unlink }}
|
|
iconOnly
|
|
title="Unlink (fork an editable copy into just this step)"
|
|
onclick={(e) => {
|
|
e.stopPropagation()
|
|
unlink()
|
|
}}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{#if showDetail && (brainParams.length > 0 || inheritedTools.length > 0)}
|
|
<dl class="mt-2 flex flex-col gap-1">
|
|
{#each brainParams as param (param.label)}
|
|
<div class="flex items-baseline gap-2 text-2xs">
|
|
<dt class="text-tertiary shrink-0 w-28">{param.label}</dt>
|
|
<dd class="text-secondary truncate" title={param.value}>{param.value}</dd>
|
|
</div>
|
|
{/each}
|
|
{#if inheritedTools.length > 0}
|
|
<div class="flex items-baseline gap-2 text-2xs">
|
|
<dt class="text-tertiary shrink-0 w-28">Tools</dt>
|
|
<dd class="flex flex-wrap gap-1">
|
|
{#each inheritedTools as tool (tool.id)}
|
|
<Badge color="gray" title={tool.id}>{toolLabel(tool)}</Badge>
|
|
{/each}
|
|
</dd>
|
|
</div>
|
|
{/if}
|
|
</dl>
|
|
{/if}
|
|
</div>
|
|
{#if unavailable === 'forbidden'}
|
|
<div class="mt-1">
|
|
<Alert type="warning" size="xs" title="Agent not accessible">
|
|
You don't have access to <span class="font-medium">{agent}</span>, so its configuration
|
|
can't be shown or edited here.
|
|
</Alert>
|
|
</div>
|
|
{:else if unavailable === 'missing'}
|
|
<div class="mt-1">
|
|
<Alert type="error" size="xs" title="Agent not found">
|
|
No saved agent exists at <span class="font-medium">{agent}</span>. It may have been
|
|
renamed or deleted. Remove the link to configure the step here, or add the agent again
|
|
from Saved agents.
|
|
<div class="flex pt-2">
|
|
<Button
|
|
unifiedSize="sm"
|
|
variant="default"
|
|
startIcon={{ icon: Unlink }}
|
|
onclick={removeLink}
|
|
>
|
|
Remove link
|
|
</Button>
|
|
</div>
|
|
</Alert>
|
|
</div>
|
|
{:else if !providerOk}
|
|
<div class="mt-1">
|
|
<Alert type="warning" size="xs" title="Model provider not accessible">
|
|
<!-- Each branch holds the whole sentence: Svelte trims whitespace at a block's edges,
|
|
which is how the path once lost the spaces around it. -->
|
|
{#if providerPath}
|
|
You don't have access to <span class="font-medium">{providerPath}</span>.
|
|
{:else}
|
|
You don't have access to this agent's model provider.
|
|
{/if}
|
|
{#if canEditAgent && !fromAgentEditor}
|
|
Edit the agent to use another, unlink it, or ask for access.
|
|
{:else}
|
|
Unlink it or ask for access.
|
|
{/if}
|
|
</Alert>
|
|
</div>
|
|
{/if}
|
|
{:else if !fromAgentEditor}
|
|
<Button
|
|
unifiedSize="sm"
|
|
variant="default"
|
|
startIcon={{ icon: Save }}
|
|
wrapperClasses="w-full"
|
|
btnClasses="w-full"
|
|
onclick={openSave}
|
|
>
|
|
Save as reusable agent
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
|
|
<Drawer bind:this={saveDrawer} size="600px">
|
|
<DrawerContent title="Save as reusable agent" on:close={() => saveDrawer?.closeDrawer()}>
|
|
<div class="flex flex-col gap-4">
|
|
<p class="text-xs text-secondary">
|
|
Save this AI agent's configuration and tools as a reusable resource. Other flows can then
|
|
link to it, and updates propagate automatically.
|
|
</p>
|
|
<!-- The path and description are the resource form's, field for field: this
|
|
drawer creates a resource too, and ResourcePathHint exists so the screens
|
|
that do cannot drift apart. -->
|
|
<Label label="Path">
|
|
<ResourcePathHint />
|
|
<Path
|
|
bind:path={newPath}
|
|
bind:error={pathError}
|
|
initialPath=""
|
|
namePlaceholder="my_agent"
|
|
kind="resource"
|
|
workspaceOverride={ws}
|
|
/>
|
|
</Label>
|
|
<ResourceDescriptionField
|
|
bind:description
|
|
label="Description"
|
|
placeholder="Describe what this agent does"
|
|
/>
|
|
{#if providerSaveError ?? legacyMemorySaveError}
|
|
<!-- Validation is `text-2xs` per the guidelines; the red is the feedback colour. -->
|
|
<p class="text-2xs text-red-600 dark:text-red-400">
|
|
{providerSaveError ?? legacyMemorySaveError}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{#snippet actions()}
|
|
<Button
|
|
variant="accent"
|
|
startIcon={{ icon: Save }}
|
|
disabled={!newPath ||
|
|
!!pathError ||
|
|
saving ||
|
|
!!(providerSaveError ?? legacyMemorySaveError)}
|
|
onclick={saveAsAgent}
|
|
>
|
|
Save agent
|
|
</Button>
|
|
{/snippet}
|
|
</DrawerContent>
|
|
</Drawer>
|