feat: reusable AI agent steps with rigid linking and edit/fork (#9825)

* feat: reusable AI agent steps with hybrid linking and evals

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: make linked AI agents rigid (read-only) with unlink-to-fork

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: show inherited agent config read-only on linked step

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: edit/update a saved agent in place via upsert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: bind linked AI agent tool inputs to host flow context

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: rebind linked AI agent tool inputs via graph tool nodes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: linked AI agent tool nodes, step test, and read-only card

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: remove ai_agent resource type migration, sync from hub instead

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: remove AI agent eval suite and run endpoint, defer to later

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: unwire eval routes, types and UI (completes eval removal)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: update reusable AI agents guide for eval removal and tool rebinding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: regenerate system prompts for AIAgent agent/tool_inputs schema

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: strip brain transforms on link, avoid dirtying flow on tool open

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: flow-local test form and linked-agent marker in read-only graph

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: store linked tool overrides as diff from resource base

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: resolve linked agent tools in read-only viewer with fallback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: use operating workspace, block non-static provider, warn on unbound tool inputs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: resolve linked parent's tools from resource for nested agent tool lookup

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: scope linked-agent tools by flow path, thread workspace to path check and embedded viewer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: strip flow-context tool inputs on agent save, drop unbound-inputs warning

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: persist agent edit mode across tool selection, show linked tool code read-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: show linked agent resource path in node definition panel

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: edit linked tool inputs in step panel, make tool nodes display-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: wire step-panel tool bindings (completes display-only pivot)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: single scroll for linked card, agent path as node label, drop fill-inputs in tool cards

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: align linked-agent UI with design tokens and components

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: separate linked tool select target from module id to unbreak agent clicks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@aanthropic.com>

* fix: save agent tool inputs verbatim, host flows override via tool_inputs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: scope agent edit state by flow path, require linked-tools scope at init

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: block saving an agent whose static provider is incomplete

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: type errors in agent tool bindings and save drawer input

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: key agent edit state by workspace, resync tool bindings on external changes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: include workspace in linked-tools scope and tool schema fingerprint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: remove unused workspace prop from FlowModuleSchemaMap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop linked-agent placeholder tool node, path label suffices

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: workspace-qualified resource links, guard stale tool schema loads

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep flow tool overrides out of the agent on edit, fold only on unlink

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: fold preserved tool overrides into the step on edit cancel

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: refuse overwriting non-agent resources on save, show memory kind on linked card

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: consume picker value, invalidate edit state on undo/reinit, cap nested agent tools

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: guard in-flight edit fork against restores, migrate edit state on rename

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: validate agent edit state by fork identity instead of path keys

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: key agent edit entries by fork marker alone, immune to editor nesting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep agent edit state across structural graph edits and flow renames

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6kq9PYqNdc5q7ubidBYAs

* fix: centralize agent edit reanchor, guard in-flight saves, seed rename scope from flow path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6kq9PYqNdc5q7ubidBYAs

* fix: ancestry-keyed edit reanchor and doc-scope sweep for republished linked tools

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6kq9PYqNdc5q7ubidBYAs

* fix: guard stale linked-tool fetches and resolve while-loop nested linked agents

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6kq9PYqNdc5q7ubidBYAs

* fix: drop empty tool override entries on revert and correct stale viewer comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6kq9PYqNdc5q7ubidBYAs

* docs: drop stale eval mention from the linked-agent comment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: deploy linked agent resource, guard viewer fetches, align tools schema

Address review findings on the reusable-agent branch:

- Cross-workspace deploy never collected a linked step's `agent` resource, so
  the deployed flow failed at runtime unless the agent already existed there.
- The read-only viewer published resolved tools without the generation guard
  flowState uses, letting a superseded link's tools win a race. Share one
  guarded publisher (`publishLinkedAgentTools`) between both call sites.
- `tools` was still required in the OpenFlow AiAgent schema while the
  deserializer defaults it, rejecting hand-authored linked steps; make it
  optional and narrow the call sites.
- Overlay `tool_inputs` in the non-linked branch too, so a flow persisted
  while a step sits in "Editing" mode still binds tools to this flow.
- Cap the linked-tools store's scope map; nothing evicted it before.
- Drop the orphaned `.sqlx` entry left by the eval removal, regenerate the
  copilot OpenFlow schema, and fix the generator's nested-`z.record` arity.
- Move `refreshFlowStateStore` out of `agentEditStore` into its own module.
- Document that linked agents' tool scripts are outside the lock pipeline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: regenerate system prompts for optional AIAgent tools

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: follow saved-agent deps on deploy, accept the linked shape in the schema

Round-18 review findings:

- Deploying a linked flow queued only the outer ai_agent resource. Follow
  `$res:` refs inside a resource value (every UI-saved agent has a provider
  resource) and the agent's own tools, which reference scripts, flows, MCP
  resources and nested linked agents by bare path.
- The AiAgent input_transforms schema still required provider/output_type,
  so it rejected the very shape linking persists (brain transforms stripped,
  flow-local inputs kept). Only user_message is always present.
- dfs traversed `value.tools` unconditionally through a cast, which throws on
  a linked module that omits it now that the field is optional.
- Trim the flow-refresh invariant comment to the 4-line limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: recurse into inline nested agent tools on deploy, require provider when unlinked

Round-19 review findings:

- The deploy walk only inspected a saved agent's top-level tools, so an inline
  nested agent tool's own scripts, flows and MCP resources were skipped.
  Recurse into it; a linked one is still queued as a resource instead.
- Normalize a `$res:`-prefixed MCP tool resource_path like other refs.
- Dropping provider/output_type from the schema's required list also let a
  standalone providerless agent validate, which deploys clean and then fails
  on every run. The constraint can't go in the schema: an `anyOf` makes
  AiAgent a union, which breaks the FlowModuleValue discriminated union it
  belongs to (verified: zod throws "Invalid discriminated union option").
  Enforce it in validateFlowModules instead, next to the other cross-module
  checks, via a shared collectProviderlessAgentIds.
- Correct the deploy paragraph in the docs: provider resources are traversed
  now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: follow linked tool_inputs overrides on deploy, untrack vitest artifact

Round-20 review findings:

- A linked step's `tool_inputs` override replaces the resource tool's default
  at runtime, so a static `$res:`/`$var:` override is the dependency the flow
  actually uses. The deploy walk queued only the saved agent, leaving runs in
  an empty target workspace to fail on the missing override target. It also
  never scanned an aiagent module's own input_transforms, since the scan was
  gated to script/rawscript/flow.
- Extract the pure walkers to deployDependencies.ts and cover them: three
  rounds have each found a further gap in this one function.
- Untrack a vitest cache artifact committed by accident, and ignore a
  repo-root node_modules/ (only per-package paths were listed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: collect inline agent provider and tool deps, correct tool_inputs docs

Round-21 review findings:

- An inline agent's provider credential sits inside an object-valued static
  transform, so the top-level string check missed it and such a flow deployed
  without its provider. Walk transform values instead of string-matching them.
- An inline agent's own tools were only partly reachable: getAllModules drops
  MCP and websearch tools, so their resources were never queued. A standalone
  agent module now recurses through agentResourceDependencies, and the module's
  own input_transforms are scanned inside aiAgentModuleDependencies so one
  function owns the whole step rather than splitting it with the caller.
- `tool_inputs` was documented as empty/absent for non-linked steps, which
  contradicts the runtime applying it when `agent` is unset so a flow persisted
  mid-Edit keeps its bindings. Describe that case in both the Rust doc and the
  OpenFlow description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep linked steps brain-free on load, gate stale agent fetches, log linked tools

Round-22 review findings:

- loadSchemaFromModule filled every AI agent schema key with a placeholder
  transform, re-adding provider/memory to a linked step that deliberately
  carries none — persisted on the next save and rejected by the generated
  Copilot schema. Fill only the flow-local keys when the step is linked.
- The linked-resource fetch was neither aborted nor tagged, so switching a
  step from agent A to B could publish A's tools under B and show A's brain
  next to B's link. Tag each result with the (workspace, path) it was fetched
  for and drop the ones that no longer match.
- "Test this step" passed no tools for a linked agent, and the log viewer
  drops tool_call entries it cannot resolve to a definition, so the agent's
  invocations vanished from the log. Pass the resolved resource tools.
- Correct the cancel-edit comment: the runtime does apply tool_inputs on an
  unlinked step, and folding is what leaves nothing for it to overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: pin the edit session across saves, resolve linked tools in the run viewer

Round-23 review findings:

- Cancel stays enabled while a save awaits its requests, and it keeps the
  `tools` array identity, so the old guard passed and the completing save
  relinked the step and cleared the edits Cancel had just kept. It also
  accepted any replacement edit marker. Pin the path being saved and require
  the marker to still hold it, which still tolerates a content-preserving
  refresh re-anchoring the marker onto a clone.
- Resolve linked agents' tools in the run/status viewer too: it reads
  module.value.tools straight from raw_flow, which is empty for a linked step,
  so AIAgentLogViewer dropped every tool_call it could not match and the graph
  drew the agent with no tool nodes. Same gap the previous commit closed for
  "Test this step" only.
- Drop the overlay call-site comment: it claimed resource defaults are
  discarded and unmatched keys ignored, while overlay_tool_inputs preserves
  defaults and inserts new keys, as its own test asserts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope linked tools without the trigger-node path, keep the standalone save guard

Round-24 review findings, both regressions from the previous commit:

- Passing `path` to the run viewer's graph also switched on its Trigger node
  (`triggerNode ? path : undefined`), which reads a TriggerContext that
  /run/[...run] does not provide — the page threw "Cannot read properties of
  undefined (reading 'triggersCount')". Give the graph a separate
  `linkedToolsPath` for the tools bucket so the two stay independent.
- The rewritten save guard tracked only the edit path, so a plain "Save as
  agent" no longer noticed the step being replaced mid-request (undo, session
  sync): the replacement has no edit path either, so the stale completion
  relinked it and stripped its brain. Keep the array-identity check when there
  is no edit session, and use path re-anchoring only when there is one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep recorded tool calls in run history, send tool_inputs from step previews

Round-25 review findings:

- The agent log viewer dropped any recorded tool_call whose definition it
  could not find among the supplied tools, so renaming or removing a tool —
  or losing read access to a linked agent's resource — erased calls that had
  actually run. Render the recorded call labelled by its function name; its
  args, logs and result come from the child job, not the definition.
- "Test this step" sent tool_inputs only for a linked step, but a step forked
  for editing has no `agent` while still carrying the flow's bindings, which
  the runtime overlays. The preview ran resource-authored defaults instead of
  the bindings under test. Send them from both branches.
- Polling a running flow replaces `job` every tick, so the run viewer re-read
  every linked agent's resource each time. Key the fetch on the set of linked
  steps instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: never discard edits made during a save, isolate the run viewer tools bucket

Round-26 review findings:

- The agent editor stays live while a save is in flight, so edits made after
  the snapshot were not in the resource yet linking stripped them from the
  step too, losing them outright. Compare the config against the snapshot on
  completion and, if it moved, leave the step alone and tell the user to save
  again.
- The run viewer published into the editor's `${ws}:${flow path}` bucket, so
  opening an older run in the preview pane could flip the edited flow's tool
  nodes to that run's agent. Key it by job instead.
- Drop the now-unreachable undefined filter in the agent log viewer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: claim the linked-tools generation on direct publishes and clears

Round-27 review findings:

- The step editor wrote resolved tools (and cleared them on unlink) straight
  into the store, leaving the fetch generation untouched. An older in-flight
  load for the previous agent then still passed its own check and overwrote
  them, so the graph and binding editor could show agent A while the step
  links to B. Claim the generation before those writes.
- Correct two comments that still described unmatched tool calls as dropped;
  they are kept and labelled by their recorded name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: retain the loaded linked agent, rebuild run logs when tools resolve

Round-28 review findings:

- Rejecting a superseded resource response left the card with nothing: a late
  reply for a previous agent replaces `linkedResource.current` and no refetch
  follows, so the linked step lost its brain, tools and provider warning until
  remount. Retain the last response that matched the current link instead.
- The agent log viewer built its module list on mount only, so a linked
  agent's asynchronously resolved tools never replaced the placeholders, and
  switching between completed runs reused the first snapshot. Rebuild on a
  value key — callers rebuild the agentJob object each render, so tracking its
  identity would reload in a loop.
- Refresh a linked-tools scope's recency when it is read, not only when it is
  published: a run viewer opens one bucket per nested job, which could
  otherwise evict the bucket a still-displayed run is using.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: supersede stale log reloads and stale tools on a link change

Round-29 review findings, both on the reloads added last round:

- Every prop change starts another loadToolCalls, and it awaits child-job
  requests before writing the shared view, so a slower reload for a previous
  run could restore its logs and tool states over the run now selected — or
  replace newly resolved definitions with an earlier empty-tools snapshot.
  Build the states locally and let only the newest load publish, including the
  parent's index-keyed job cache.
- While a newly linked agent resolves, the previous agent's tools stayed in
  the store, so its bindings were editable against a step already linked
  elsewhere, and a failed load left them indefinitely. Clear them once the
  link moves away from what this component published; tools resolved at flow
  load are untouched, so selecting a step still doesn't flicker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve a run's linked agents in the run's own workspace

Round-30 review finding: the run viewer fetched linked agent resources with
the navigation workspace, but session and fork previews render it with
`workspaceId` pointing elsewhere. Those runs resolved nothing — or an
unrelated resource sharing the path — losing tool nodes and log definitions.
Prefer the explicit override, then the job's own workspace. The store scope
stays keyed on `workspace` so it still matches what FlowGraphV2 reads; the
job id in the key already makes the bucket unique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refetch a run viewer's linked tools if its scope is evicted

Round-31 review nit: the viewer publishes one scope per mounted nested job,
hidden ones included, so a loop with many loaded iterations can push a
displayed scope past the store's cap. Nothing refetched it afterwards — the
set of linked steps had not changed — leaving the run without tool nodes or
log definitions. Track the store and republish when the bucket is gone;
publishing always writes a key, so this settles instead of looping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: retain in-use linked-tool scopes instead of refetching evicted ones

Round-32 review findings. Republishing an evicted scope settles for one
scope but not against the cap: with more than 32 mounted nested jobs holding
linked agents, restoring one necessarily evicts another, and that mutation
reran every viewer's effect — an endless round of resource requests.

Hold a scope for as long as a viewer is mounted and skip retained scopes when
evicting, so buckets in use are never dropped and nothing has to refetch. The
cap yields to correctness when everything mounted is in use.

Dropping the publish key also restores refetching when the fetch workspace
changes for an otherwise unchanged job and link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: guard non-static brain edits during save, retain every displayed scope

Round-33 review findings:

- The in-flight edit guard compared the saved config, which holds only static
  brain values. A computed system prompt, memory or temperature changed while
  the save was awaiting the API therefore compared equal, and linking stripped
  it with no warning. Compare what linking actually discards — every brain
  transform and the tools — leaving the flow-local inputs free to change.
- Retaining run-viewer scopes made them fill the cap, and eviction then picked
  any unretained scope, including the editor bucket a user is looking at, with
  nothing to refetch it. Retain the scope each graph draws from for as long as
  it is mounted, so every displayed bucket is protected.
- A failed agent job has no parseable action list; the loader returned early
  and left the previously selected step's tool tree under the new header.
  Clear the view instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve only flow modules in viewer scans, prune scopes on release

Round-34 review findings:

- Both viewer scans used the default dfs, which descends into agent tools, and
  published each linked agent under its bare id. Tool ids imported from a
  resource are not flow-global, so a nested linked agent sharing an id with a
  top-level step superseded that step's fetch and showed its tools instead.
  Scan flow modules only — the graph resolves the store per module node.
- Scopes skipped while retained were never reconsidered, so closing views left
  the store over its cap for the tab's life. Prune on release too.
- Correct two comments that still argued the premises the retain mechanism and
  the read-recency policy replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: don't report success when a save left the step unlinked

Round-35 review nits:

- persist warns that changes made during the save are not in the resource and
  leaves the step alone, but both callers then toasted success unconditionally,
  burying the only actionable message. Report whether the step was linked.
- Condense the tool_inputs invariant to the four-line limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: seed the published link at mount, keep run history for toolless agents

Round-36 review findings:

- `publishedFor` started unset, but initFlowState has already published for the
  step's link by then. A link change landing before this component's own
  request therefore skipped the clear, leaving the previous agent's tools under
  the new link — indefinitely if the new one fails. Seed it from the link at
  mount.
- A standalone agent that omits `tools` kept `undefined` here, and the gate
  downstream then hid the AI message and tool-call history behind the generic
  result view. Default to an empty list like the other consumers.
- A save that lands after the step was replaced writes the resource but leaves
  the step alone; say so instead of closing the drawer with no outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: qualify nested agent tool store keys, keep an empty tools identity stable

Round-37 review findings:

- The step editor keyed the linked-tools store by the bare module id for
  nested agent tools too. Those ids come from a resource and are not
  flow-global, so a nested linked agent sharing an id with a top-level step
  read that step's tools — then overwrote them once its own fetch landed.
  Qualify the key by the parent agent, as the edit store already does; flow
  modules keep the bare id the graph looks up.
- The `tools` binding handed the editor a fresh [] on every read when the
  module omits the field — a shape this PR made valid — so the save guard's
  identity check never matched and such a step could never link. Read through
  one shared empty array instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: accept the first tool on an agent module that omits tools

Round-38 review nit: the graph's tool insert required an existing `tools`
array, so a module authored without the field — valid since `tools` became
optional — swallowed the insert while still pushing history and dispatching a
change. Create the array on first use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: don't evict a scope on the write that created it, and cover the store

Round-39 review findings:

- A rename removed the retained old key from the order but the new one is not
  retained until readers re-run, so eviction deleted the fresh bucket
  immediately. Reorder without evicting; the next publish or release enforces
  the cap, by which point the new key is held.
- Writing the test for that surfaced the same shape in touchScope: it evicts
  right after appending, so once every older scope is retained the scope just
  published was the only eligible victim and was dropped at once. Exclude the
  scope being written.

Add the store's first test: retention, eviction past the cap, pruning on
release, and the rename handoff — four rounds landed fixes here with nothing
pinning the behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: re-resolve linked agents when a wholesale edit changes the links

Round-40 review findings:

- Undo/redo, YAML apply, AI apply and session restore swap a step's `agent`
  without re-running initFlowState, and the step editor only watches the step
  it is mounted on — so an unselected step kept showing, and binding against,
  the previous agent's tools. Re-resolve from the editor whenever the set of
  links changes.
- Document that linked resolution is live rather than pinned: an edit landing
  mid-run affects steps that have not started, and a nested agent tool looks
  its definition up by id when its own job starts, so it can run a changed
  definition. Pinning would mean carrying the resolved definition into the
  child job instead of its id; inline agents are unaffected because their
  tools are snapshotted with the flow value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: per-module empty tools identity, invalidate tools when a link is replaced

Both findings are over-corrections in the two preceding commits:

- The shared empty-tools array made identity stable, but stable everywhere: a
  wholesale edit that keeps the module id reuses the component, so when both
  the old and the replacement module omit tools the save guard saw no change
  and could link and clear the replacement. Hand out one empty array per
  module value, which a replacement always renews.
- The editor's link watcher resolved the replacement agent without dropping
  the previous one's tools first, so a step selected before the fetch landed
  still showed agent A under link B — and the freshly mounted editor seeds
  itself from B, so it could not tell. Clear the entry when the link for a
  module changes, seeding the map from the graph so the first run doesn't
  refetch what initFlowState just resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reserve graph space for linked tools, re-resolve only changed links

Round-41 review nits:

- The layout reservation read the module's own `tools`, which is empty for a
  linked agent, so its display-only tool nodes were drawn over the node above
  in read-only viewers. Count the resolved tools for a linked step.
- The editor's link watcher refetched every linked agent on each run. Resolve
  only modules whose link actually changed, and skip the pass entirely on a
  rename, where the scope sweep has already carried the buckets over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: protect a renamed scope until it is retained, drop the phantom tool row

Round-42 review nits:

- Readers release the old scope before retaining the new one, so a migrated
  bucket is unretained in between and, over the cap with everything else held,
  was the only thing eviction could take. Protect a just-migrated scope until a
  reader retains it, and cover that release/retain order in the store test.
- The layout reserved an add-tool row for linked agents, which have no add-tool
  node, leaving dead vertical space. Match computeAIToolNodes.
- Re-resolving links no longer short-circuits on a rename: comparing each
  module still costs nothing when only the path changed, and a restore that
  renames and relinks in one tick now gets both.
- Hoist the duplicated linked-tools lookup in the graph's store update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: kill a scope's in-flight fetches before migrating it

Round-43 review finding: fetch generations are keyed by (scope, module), so a
resolution still running against the pre-rename scope keeps a valid generation
there. It publishes into the old bucket after the rename, and the doc-scope
sweep — which gives the source precedence — carries it forward over a link
resolved since under the new scope, leaving the graph and binding editor on the
previous agent's tool ids with nothing to refetch them.

Invalidate the source scope's fetches before each migration, and pin the
behaviour: the new test fails without the invalidation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: re-resolve links a scope sweep cancelled, and only sweep a real bucket

Round-44 review findings, both on the previous commit:

- Invalidating the source scope killed fetches that were perfectly current —
  a link still loading when the rename landed — and nothing restarted them,
  because the watcher already records that link. Resolve again, in the
  destination, every link the migration left without tools.
- The doc-scope sweep ran on every store version bump, so during a draft
  refresh the first completed fetch cancelled the others mid-flight. Skip the
  sweep entirely when the source scope holds nothing.
- Condense a six-line invariant to the four-line limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: split rename from doc sweep, hide brain fields of nested linked agents

Round-45 review findings:

- Two reviewers disagreed about invalidating a scope whose bucket is empty,
  because the two callers differ. A rename is a cut-off: every fetch still
  running against the old scope is stale whether or not anything resolved
  there, so it always invalidates. The doc-scope sweep has no cut-off — those
  fetches belong to the refresh in progress — so it still waits until that
  scope holds something.
- Recording the swept links as published undid the rename+relink fix: a
  restore that renames and swaps a link in one tick would keep the previous
  agent's tools with nothing to refetch them. Leave that comparison to the
  watcher, which compares links rather than presence.
- A nested agent that is itself linked was offered the whole agent schema in
  the tool bindings, but the runtime overlays only its flow-local inputs, so
  the rest were collected and dropped. Show what actually applies.
- Condense the hybrid-linking comment to the constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: don't resolve a shared agent's tool defaults when loading it

Round-46 review finding: the whole agent resource was interpolated before
tool_inputs was overlaid, so each tool's default `$res:`/`$var:` resolved
first. A host flow overriding a default that points at the author's resource
still had to resolve that resource, and an unused tool whose default is
unreadable in the consumer's permission context failed the agent outright —
defeating the point of sharing an agent across contexts.

Read the resource raw, overlay the host's overrides, and interpolate only the
brain; each tool resolves its effective inputs when it executes. The nested
tool lookup reads raw too, since it only needs definitions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: interpolate the brain before overlaying caller inputs

Round-47 review findings, all on the previous commit:

- user_message and user_attachments were inserted before interpolation, so
  they went through it a second time: a user message of `$WM_TOKEN` expanded
  to the job token and was sent to the model provider. Interpolate the
  resource first, then overlay the already-resolved flow-local inputs.
- The relink watcher skips tool nodes, so a linked agent nested as a tool kept
  the previous agent's entry through undo, YAML/AI apply or a session restore,
  and the step editor seeds itself from the new link and cannot tell. Emit the
  ancestry-qualified key for those too.
- Correct the guide, which still named the interpolation path this branch
  replaced, and condense two invariants to the four-line limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: deploy $jsonvar deps, key run logs by tool identity, seed only top links

Round-48 review findings:

- The deploy walkers recognised `$res:` and `$var:` but not `$jsonvar:`, which
  the worker resolves too, so a secret referenced that way by an agent brain,
  a saved tool default or a host override never reached the target workspace.
- The run log rebuilt only when a tool's name or the tool count changed, so a
  refreshed resource that altered a tool's path, code or id behind the same
  name kept showing the old definition. Key on the array identity instead: the
  store swaps it exactly when the contents differ.
- Nested linked agents were seeded as already published, but initFlowState
  resolves only top-level links, so their tools never loaded until their
  editor was opened. Seed what initFlowState actually publishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let the watcher's fetch survive the step editor's stale-clear

Round-49 review nits:

- On a relink the step editor claimed the fetch generation before clearing the
  previous agent's tools, which discarded the watcher's already-running fetch
  for the new link. The tool nodes then only appeared if the step stayed
  selected until the editor's own refetch landed. Clear without claiming: the
  watcher superseded the old fetch when the link changed, so nothing stale can
  return. Unlink still claims, since no watcher fetch covers it.
- Condense the store's opening invariant to the four-line limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: condense the stale-clear invariant

Round-50 review nit. Also records why the branch deliberately doesn't claim a
fetch generation: a reviewer asked for the opposite this round, but writing
`agent` re-runs the editor's watcher, which supersedes the old fetch and
starts one for the new link — claiming here would discard it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: guard Edit/Unlink by step identity, not just the link path

Round-51 review finding: forkFromResource compared only the agent path after
its fetch, so a module replaced mid-request while keeping the same link passed
the check — the stale continuation then wrote the fetched brain and tools into
the replacement and unlinked it. Compare the step's own `tools` array too,
which is one instance per module value and so identifies the step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: report an Edit or Unlink abandoned because the step changed

Round-52 non-blocking note: forkFromResource returns undefined when the step
was replaced mid-request, and both callers treated that as do-nothing, so the
click looked ignored. Say what happened, as the save path already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@aanthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-28 01:38:16 +02:00
committed by GitHub
co-authored by Claude Opus 5 hugocasa Claude Opus 4.8
parent 727d22b9a1
commit 3b95a2d096
56 changed files with 3023 additions and 161 deletions
+1
View File
@@ -24,6 +24,7 @@ rust-client/Cargo.toml
# Symlinked cache directories (for git worktrees)
backend/target
node_modules/
frontend/node_modules
typescript-client/node_modules
ai_evals/node_modules
+17 -3
View File
@@ -1070,6 +1070,16 @@ pub enum FlowModuleValue {
tag: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
omit_output_from_conversation: bool,
/// When set, the agent brain config (provider/model/system prompt/etc.) and tools are
/// resolved at runtime from this `ai_agent` resource path (hybrid linking). The module's
/// `input_transforms` then only carry the flow-local inputs (user_message/user_attachments).
#[serde(default, skip_serializing_if = "Option::is_none")]
agent: Option<String>,
/// Binds an agent's tools to *this* flow's context, keyed by tool id then input key, without
/// mutating the shared resource. Overlaid onto the tools' `input_transforms` at runtime,
/// `agent` set or not: a step forked for editing keeps these until saved back or unlinked.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
tool_inputs: HashMap<String, HashMap<String, InputTransform>>,
},
}
@@ -1105,6 +1115,8 @@ struct UntaggedFlowModuleValue {
assets: Option<Vec<AssetWithAltAccessType>>,
tools: Option<Vec<AgentTool>>,
omit_output_from_conversation: Option<bool>,
agent: Option<String>,
tool_inputs: Option<HashMap<String, HashMap<String, InputTransform>>>,
pass_flow_input_directly: Option<bool>,
squash: Option<bool>,
#[serde(flatten)]
@@ -1203,13 +1215,15 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
"identity" => Ok(FlowModuleValue::Identity),
"aiagent" => Ok(FlowModuleValue::AIAgent {
input_transforms: untagged.input_transforms.unwrap_or_default(),
tools: untagged
.tools
.ok_or_else(|| serde::de::Error::missing_field("tools"))?,
// Tools default to empty: a linked agent (see `agent`) resolves its tools from
// the referenced resource, so the module itself may carry none.
tools: untagged.tools.unwrap_or_default(),
tag: untagged.tag,
omit_output_from_conversation: untagged
.omit_output_from_conversation
.unwrap_or(false),
agent: untagged.agent,
tool_inputs: untagged.tool_inputs.unwrap_or_default(),
}),
other => Err(serde::de::Error::unknown_variant(
other,
+266 -11
View File
@@ -38,7 +38,7 @@ use windmill_common::{
error::{self, Error},
flow_conversations::MessageType,
flow_status::AgentAction,
flows::{FlowModule, FlowModuleValue, ToolValue},
flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue},
get_latest_hash_for_path,
jobs::JobKind,
scripts::get_full_hub_script_by_path,
@@ -49,7 +49,9 @@ use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob};
use crate::{
ai::stream_event_processor::StreamEventProcessor,
common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier},
common::{
build_args_map, resolve_job_timeout, transform_json_value, OccupancyMetrics, StreamNotifier,
},
handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome},
};
@@ -116,19 +118,51 @@ fn find_module_by_id(
Ok(found)
}
fn find_ai_agent_tool_module_in_parent_agent(
async fn find_ai_agent_tool_module_in_parent_agent(
modules: &Vec<FlowModule>,
parent_agent_step_id: &str,
tool_module_id: &str,
client: &AuthedClient,
) -> Result<Option<FlowModule>, Error> {
let Some(parent_agent_module) = find_module_by_id(modules, parent_agent_step_id)? else {
return Ok(None);
};
let FlowModuleValue::AIAgent { tools, .. } = parent_agent_module.get_value()? else {
let FlowModuleValue::AIAgent { tools, agent, .. } = parent_agent_module.get_value()? else {
return Ok(None);
};
// A linked parent carries no tools on the module (they live in the resource, resolved only in
// the main execution branch). Resolve them from the resource here too, so a nested agent tool
// of a saved+linked agent can still be located when it runs as its own job.
let tools = if let Some(agent_ref) = agent.as_deref() {
let agent_path = agent_ref
.trim_start_matches("$res:")
.trim_start_matches("res://");
// Definitions only: resolving their defaults here would hit the same inaccessible resources.
let resource_value = client
.get_resource_value::<serde_json::Value>(agent_path)
.await
.map_err(|e| {
Error::internal_err(format!(
"failed to load ai_agent resource {agent_path}: {e}"
))
})?;
match resource_value {
serde_json::Value::Object(mut map) => match map.remove("tools") {
Some(t) => serde_json::from_value::<Vec<AgentTool>>(t).map_err(|e| {
Error::internal_err(format!(
"invalid tools in ai_agent resource {agent_path}: {e}"
))
})?,
None => Vec::new(),
},
_ => Vec::new(),
}
} else {
tools
};
for tool in tools {
if tool.id == tool_module_id {
return Ok(Option::<FlowModule>::from(&tool));
@@ -175,6 +209,36 @@ async fn fetch_script_description(db: &DB, w_id: &str, hash: i64) -> Option<Stri
.filter(|d| !d.is_empty())
}
/// Overlay a linked step's host-local tool wiring onto the agent resource's tools. For each tool
/// id present in `tool_inputs`, merge its per-input transforms into that tool's `input_transforms`
/// (step wins). Only `FlowModule` tools carry input transforms; MCP/websearch tools are skipped.
fn overlay_tool_inputs(
tools: &mut [AgentTool],
tool_inputs: &HashMap<String, HashMap<String, InputTransform>>,
) {
if tool_inputs.is_empty() {
return;
}
for tool in tools.iter_mut() {
let Some(overrides) = tool_inputs.get(&tool.id) else {
continue;
};
let ToolValue::FlowModule(fmv) = &mut tool.value else {
continue;
};
let input_transforms = match fmv {
FlowModuleValue::Script { input_transforms, .. }
| FlowModuleValue::RawScript { input_transforms, .. }
| FlowModuleValue::FlowScript { input_transforms, .. }
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
_ => continue,
};
for (key, transform) in overrides {
input_transforms.insert(key.clone(), transform.clone());
}
}
}
pub async fn handle_ai_agent_job(
// connection
conn: &Connection,
@@ -196,14 +260,20 @@ pub async fn handle_ai_agent_job(
has_stream: &mut bool,
) -> Result<Box<RawValue>, Error> {
// build_args_map returns None if no $res:/$var: transforms needed, in which case use original args
let args = match build_args_map(job, client, conn).await? {
let local_args = match build_args_map(job, client, conn).await? {
Some(transformed) => transformed,
None => job.args.as_ref().map(|a| a.0.clone()).unwrap_or_default(),
};
let args = serde_json::from_str::<AIAgentArgs>(&serde_json::to_string(&args)?)?;
// Handle dry_run mode - check credentials without making API calls
if args.credentials_check {
// Handle dry_run mode - check credentials without making API calls.
// The credentials check is always invoked inline (provider present, no agent link and no
// parent flow), so it resolves before any flow/agent-resource context is fetched.
let is_credentials_check = local_args
.get("credentials_check")
.map(|v| v.get().trim() == "true")
.unwrap_or(false);
if is_credentials_check {
let args = serde_json::from_str::<AIAgentArgs>(&serde_json::to_string(&local_args)?)?;
return handle_credentials_check(&args.provider).await;
}
@@ -276,7 +346,9 @@ pub async fn handle_ai_agent_job(
&value.modules,
parent_agent_step_id,
flow_step_id,
)?
client,
)
.await?
} else {
find_module_by_id(&value.modules, flow_step_id)?
};
@@ -289,14 +361,118 @@ pub async fn handle_ai_agent_job(
let summary = module.summary.clone();
let FlowModuleValue::AIAgent { tools, omit_output_from_conversation, .. } =
module.get_value()?
let FlowModuleValue::AIAgent {
tools: module_tools,
omit_output_from_conversation,
agent,
tool_inputs,
..
} = module.get_value()?
else {
return Err(Error::internal_err(
"AI agent module is not an AI agent".to_string(),
));
};
// A linked step takes its brain and tools from the resource and keeps only the flow-local
// inputs (user_message/user_attachments) of its own; both stay rigid, so the one thing it may
// bind to this flow is the tools' inputs, overlaid from `tool_inputs` below.
let (args, tools): (AIAgentArgs, Vec<AgentTool>) = if let Some(agent_ref) = agent.as_deref() {
let agent_path = agent_ref
.trim_start_matches("$res:")
.trim_start_matches("res://");
// Read raw and interpolate only the brain below. Interpolating the whole resource would also
// resolve each tool's default `$res:`/`$var:`, which a host flow may be overriding and which
// may be unreadable to whoever runs this flow — an unused tool could then fail the agent.
let resource_value = client
.get_resource_value::<serde_json::Value>(agent_path)
.await
.map_err(|e| {
Error::internal_err(format!(
"failed to load ai_agent resource {agent_path}: {e}"
))
})?;
let mut config = match resource_value {
serde_json::Value::Object(map) => map,
_ => {
return Err(Error::internal_err(format!(
"ai_agent resource {agent_path} must be a JSON object"
)))
}
};
let mut tools = match config.remove("tools") {
Some(t) => serde_json::from_value::<Vec<AgentTool>>(t).map_err(|e| {
Error::internal_err(format!(
"invalid tools in ai_agent resource {agent_path}: {e}"
))
})?,
None => Vec::new(),
};
overlay_tool_inputs(&mut tools, &tool_inputs);
let brain = transform_json_value(
"ai_agent",
client,
&job.workspace_id,
serde_json::Value::Object(config),
job,
conn,
0,
)
.await?;
let mut brain = match brain {
serde_json::Value::Object(map) => map,
_ => {
return Err(Error::internal_err(format!(
"ai_agent resource {agent_path} must be a JSON object"
)))
}
};
// Only after interpolating the resource: these are caller-controlled and already resolved by
// build_args_map, so passing them through it again would expand contextual values —
// `$WM_TOKEN` in a user message would reach the model provider.
for key in ["user_message", "user_attachments"] {
if let Some(v) = local_args.get(key) {
brain.insert(
key.to_string(),
serde_json::from_str(v.get()).unwrap_or(serde_json::Value::Null),
);
}
}
let args = serde_json::from_value::<AIAgentArgs>(serde_json::Value::Object(brain))
.map_err(|e| {
Error::internal_err(format!(
"invalid ai_agent resource config {agent_path}: {e}"
))
})?;
(args, tools)
} else {
let args = serde_json::from_str::<AIAgentArgs>(&serde_json::to_string(&local_args)?)?;
// "Edit" on a linked step clears `agent` but keeps the host's `tool_inputs` until Save or
// Cancel folds them back, so overlay them here too: a flow persisted mid-edit must still
// bind its tools to this flow's context rather than the agent author's.
let mut tools = module_tools;
overlay_tool_inputs(&mut tools, &tool_inputs);
(args, tools)
};
// Nesting is capped at flow → agent → nested agent. When this job is itself a nested tool,
// a linked resource's tool set may still contain AIAgent tools (the editor can't constrain a
// shared resource); don't advertise them — invoking one would only fail the depth check as a
// third-level agent.
let tools = if direct_parent_job_kind == JobKind::AIAgent {
tools
.into_iter()
.filter(|t| {
!matches!(
&t.value,
ToolValue::FlowModule(FlowModuleValue::AIAgent { .. })
)
})
.collect()
} else {
tools
};
// Separate Windmill tools from MCP tools, websearch, and extract MCP resource configs
let mut windmill_modules: Vec<FlowModule> = Vec::new();
// Explicit per-tool descriptions keyed by tool id. When set, these override the
@@ -1452,6 +1628,85 @@ mod tests {
}
}
#[test]
fn overlay_tool_inputs_binds_matching_flowmodule_tool_only() {
fn js(expr: &str) -> InputTransform {
InputTransform::Javascript { expr: expr.to_string() }
}
fn script_tool(id: &str, key: &str, expr: &str) -> AgentTool {
let mut its = HashMap::new();
its.insert(key.to_string(), js(expr));
AgentTool {
id: id.to_string(),
summary: None,
description: None,
value: ToolValue::FlowModule(FlowModuleValue::Script {
input_transforms: its,
path: "u/test/tool".to_string(),
hash: None,
tag_override: None,
is_trigger: None,
pass_flow_input_directly: None,
}),
}
}
fn script_its(tool: &AgentTool) -> &HashMap<String, InputTransform> {
let ToolValue::FlowModule(FlowModuleValue::Script { input_transforms, .. }) =
&tool.value
else {
panic!("expected script tool")
};
input_transforms
}
// "a" gets rebound, "b" is left alone, the MCP tool is skipped even though it has an override.
let mut tools = vec![
script_tool("a", "x", "authoring_flow_expr"),
script_tool("b", "y", "keep_me"),
AgentTool {
id: "m".to_string(),
summary: None,
description: None,
value: ToolValue::Mcp(windmill_common::flows::McpToolValue {
resource_path: "u/test/mcp".to_string(),
include_tools: vec![],
exclude_tools: vec![],
}),
},
];
let mut tool_inputs: HashMap<String, HashMap<String, InputTransform>> = HashMap::new();
tool_inputs.insert(
"a".to_string(),
HashMap::from([
("x".to_string(), js("flow_input.tenant")),
("z".to_string(), js("results.step1")),
]),
);
tool_inputs.insert(
"m".to_string(),
HashMap::from([("q".to_string(), js("ignored"))]),
);
overlay_tool_inputs(&mut tools, &tool_inputs);
// "a": existing key replaced, new key added.
let a = script_its(&tools[0]);
assert!(
matches!(a.get("x"), Some(InputTransform::Javascript { expr }) if expr == "flow_input.tenant")
);
assert!(
matches!(a.get("z"), Some(InputTransform::Javascript { expr }) if expr == "results.step1")
);
// "b": no override for it, untouched.
let b = script_its(&tools[1]);
assert!(
matches!(b.get("y"), Some(InputTransform::Javascript { expr }) if expr == "keep_me")
);
// MCP tool: not a FlowModule, left as-is.
assert!(matches!(&tools[2].value, ToolValue::Mcp(_)));
}
#[test]
fn tool_description_prefers_explicit_over_derived_and_name() {
assert_eq!(
@@ -1250,6 +1250,8 @@ async fn lock_modules(
mut tools,
tag,
omit_output_from_conversation,
agent,
tool_inputs,
} => {
// Extract FlowModules from tools and track their original indices
// MCP tools don't need locking, so we filter them out
@@ -1302,6 +1304,8 @@ async fn lock_modules(
tools,
tag,
omit_output_from_conversation,
agent,
tool_inputs,
}
.into();
}
File diff suppressed because one or more lines are too long
+65
View File
@@ -0,0 +1,65 @@
# Reusable AI Agents
An AI agent flow step can be saved as a **reusable agent** — a resource of the built-in
`ai_agent` resource type that bundles the agent's brain (provider/model, system prompt,
temperature, output schema, memory…) and its tool set. Other flows can link to the same
agent, and edits to the agent propagate to every linked step.
The `ai_agent` resource type is defined in the hub (windmill-integrations) and synced into
every workspace via the standard cached-resource-type sync, like other built-in types.
## Rigid linking
`FlowModuleValue::AIAgent` has an optional `agent` field holding the resource path, plus a
`tool_inputs` map (per-tool host-flow input overrides). When `agent` is set:
- The brain config and tools are resolved at runtime from the resource
(`windmill-worker/src/ai_executor.rs`): the brain is interpolated, so a nested provider `$res:`
credential resolves automatically.
- The step keeps only the flow-local inputs (`user_message`, `user_attachments`) in its own
`input_transforms`; the brain and tools stay in the resource (read-only in the step).
- The agent carries its tools' default input bindings verbatim as authored (static, AI-filled,
or flow expressions), so saving round-trips losslessly. Each host flow overrides what it
needs: `tool_inputs` stores per-tool overrides (a diff from the resource tool's own
transforms) that overlay onto the matching tools at runtime. Editing on a linked step edits
the flow's use of the agent; editing under the "Editing" banner edits the agent itself.
In the flow editor, the AI agent step's **Step Input** tab shows a single read-only card
(*linked to <path>*, with the inherited brain + tools and an explanatory tooltip) plus
*Edit* (fork into the editable step, Save changes upserts back and re-links) and *Unlink*
(fork the resolved config — including any `tool_inputs` — back into the step as a one-off).
A linked agent's tools appear as display-only graph tool nodes (clicking one selects the
agent step); below the step's inputs, each tool gets a section with the standard schema-aware
input editors (prop picker included) and a read-only view of its code — edits persist into
`tool_inputs`.
Sharing works through standard resource folder permissions (save agents under `f/...`).
Only the agent's brain is interpolated when the step runs. A tool's own `$res:`/`$var:` defaults are
left alone and resolved when that tool executes, so a host flow can override a default pointing at a
resource it cannot read — and an unused tool whose default is inaccessible never fails the agent.
## Resolution is live, not pinned
A linked step resolves its agent resource when the step runs — that is what makes an edit propagate
to every linked flow. It also means a run is not a snapshot: editing the agent while a flow is
in-flight affects steps that have not started yet. The same applies one level down, where the effect
is sharper: a *nested* agent tool of a linked agent runs as its own job and looks its definition up
in the resource again by tool id, so an edit landing between the LLM selecting that tool and the
tool starting can run the changed definition, or fail if the tool was removed. Pinning would require
carrying the resolved definition into the child job rather than its id. Inline (unlinked) agents are
unaffected: their tools live in the flow value, which is snapshotted with the run.
## Dependencies and locks
A linked step carries `tools: []`, and no dependency job ever visits the `ai_agent` resource, so the
tool scripts inside it are outside the lockfile and dependency-map pipelines: `lock_modules` has
nothing to lock on the step, and `FlowValue::traverse_leafs` sees no leaf for them. Consequences:
- Raw-script tools saved into an agent keep whatever `lock` they had on the authoring step (`null`
if that step was never deployed), and every linked flow resolves their dependencies at job time.
- Script tools referenced by path are invisible to redeploy cascades — republishing such a script
does not re-lock the flows that link the agent.
Deploying a linked flow to another workspace pulls the `ai_agent` resource in as a dependency, and
from there its provider resource and its tools' scripts, flows, MCP resources and nested agents.
+14 -3
View File
@@ -96,9 +96,20 @@ zodCode = zodCode.replace(/z\\.literal\\(\"__CIRCULAR_REF_FLOWMODULEVALUE__\"\\)
// Fix z.record() calls for Zod 4 compatibility
// In Zod 4, z.record(valueSchema) means keySchema, not valueSchema
// We need z.record(z.string(), valueSchema) for string keys with typed values
zodCode = zodCode.replace(/z\\.record\\(z\\./g, 'z.record(z.string(), z.');
valueSchemaExport = valueSchemaExport.replace(/z\\.record\\(z\\./g, 'z.record(z.string(), z.');
// We need z.record(z.string(), valueSchema) for string keys with typed values.
// A single pass can't reach a record nested directly inside another record (the outer match
// consumes the inner one's prefix), so repeat until stable; the lookahead skips already-fixed
// calls and keeps that loop terminating.
const fixRecordArity = (code) => {
let prev;
do {
prev = code;
code = code.replace(/z\\.record\\((?!z\\.string\\(\\), )z\\./g, 'z.record(z.string(), z.');
} while (code !== prev);
return code;
};
zodCode = fixRecordArity(zodCode);
valueSchemaExport = fixRecordArity(valueSchemaExport);
// Fix refinement API for Zod 4 compatibility
// Zod 4 uses 'errors' instead of 'unionErrors' and doesn't have ctx.path
@@ -10,7 +10,7 @@
import { workspaceStore } from '$lib/stores'
import FlowLogViewerWrapper from './FlowLogViewerWrapper.svelte'
import { z } from 'zod'
import { onMount } from 'svelte'
import { untrack } from 'svelte'
import type { AgentTool } from './flows/agentToolUtils'
type AgentActionWithContent = NonNullable<FlowStatusModule['agent_actions']>[number] & {
@@ -69,7 +69,11 @@
const fakeModuleStates: Record<string, GraphModuleState> = $state({})
async function loadMissingJobs(agentActions: AgentActionWithContent[]) {
async function loadMissingJobs(
agentActions: AgentActionWithContent[],
gen: number
): Promise<Record<string, GraphModuleState>> {
const states: Record<string, GraphModuleState> = {}
const promises = agentActions.map(async (toolCall, idx) => {
if (toolCall.type === 'tool_call') {
let job: Job | undefined = storedToolCallJobs?.[idx]
@@ -80,30 +84,33 @@
workspace: workspaceId ?? $workspaceStore!
})
}
fakeModuleStates[idx.toString()] = {
states[idx.toString()] = {
args: job.args,
type: job['success'] ? 'Success' : 'Failure',
logs: job.logs,
result: job['result'],
job_id: toolCall.job_id
}
onToolJobLoaded?.(job, idx)
// Keyed by index in the parent's cache, so a superseded run must not write into it.
if (gen === loadGen) {
onToolJobLoaded?.(job, idx)
}
} else if (toolCall.type === 'mcp_tool_call') {
fakeModuleStates[idx.toString()] = {
states[idx.toString()] = {
type: 'Success',
args: toolCall.arguments ?? {},
logs: '',
result: toolCall.content
}
} else if (toolCall.type === 'web_search') {
fakeModuleStates[idx.toString()] = {
states[idx.toString()] = {
type: 'Success',
args: {},
logs: '',
result: toolCall.content
}
} else {
fakeModuleStates[idx.toString()] = {
states[idx.toString()] = {
type: 'Success',
args: {},
logs: '',
@@ -113,13 +120,26 @@
})
await Promise.all(promises)
return states
}
let job: Partial<Job> | undefined = $state(undefined)
async function loadToolCalls() {
// Every prop change starts another load; only the newest may write the shared view, else a
// slower reload for a previous run restores its logs over the one now selected.
let loadGen = 0
async function loadToolCalls(agentJob: Props['agentJob'], tools: AgentTool[]) {
const gen = ++loadGen
let parsedResult = resultSchema.safeParse(agentJob.result)
if (!parsedResult.success) {
console.error('Invalid result', parsedResult.error)
// A failed agent job has no parseable action list. Drop the view rather than leave the
// previously selected step's tool tree rendered under this one's header.
if (gen === loadGen) {
job = undefined
for (const key of Object.keys(fakeModuleStates)) {
delete fakeModuleStates[key]
}
}
return
}
let agentActions = parsedResult.data.messages
@@ -154,7 +174,14 @@
)
.filter((m) => m !== undefined)
await loadMissingJobs(agentActions)
const states = await loadMissingJobs(agentActions, gen)
if (gen !== loadGen) {
return
}
for (const key of Object.keys(fakeModuleStates)) {
delete fakeModuleStates[key]
}
Object.assign(fakeModuleStates, states)
job = {
...agentJob,
@@ -187,21 +214,56 @@
}
} else {
const module = tools.find((m) => m.summary === toolCall.function_name)
// A definition can be missing for a call that did run: the tool was renamed or
// removed since, or it belongs to a linked agent whose resource is no longer
// readable. Keep the recorded call — its args, logs and result come from the
// child job — rather than dropping it from the history.
return module
? ({
...module,
id: idx.toString()
} as FlowModule)
: undefined
: ({
id: idx.toString(),
value: { type: 'identity' as const },
summary: toolCall.function_name
} as FlowModule)
}
})
.filter((m) => m !== undefined)
}
}
}
onMount(() => {
loadToolCalls()
// Identity, not a summary digest: a refreshed resource can change a tool's path, code or id while
// keeping its name and count. The store swaps the array only when its contents actually differ,
// so one version per array instance tracks that exactly. An empty list is always the same key,
// since callers hand out a fresh [] for it on every render.
const toolsVersions = new WeakMap<object, number>()
let nextToolsVersion = 0
function toolsIdentity(list: AgentTool[]): string {
if (list.length === 0) {
return 'empty'
}
let version = toolsVersions.get(list)
if (version === undefined) {
version = ++nextToolsVersion
toolsVersions.set(list, version)
}
return String(version)
}
// Rebuild when the inputs change, not only on mount: a linked agent's tools resolve
// asynchronously after the first render, and switching between completed runs reuses this
// component — either would otherwise keep the first snapshot. Keyed by value, because callers
// rebuild the `agentJob` object on every render and identity alone would reload in a loop.
let reloadKey = $derived(`${agentJob?.id ?? ''}|${toolsIdentity(tools)}`)
$effect(() => {
reloadKey
untrack(() => {
if (agentJob) {
loadToolCalls(agentJob, tools)
}
})
})
</script>
@@ -1,5 +1,10 @@
<script lang="ts">
import { createEventDispatcher, untrack } from 'svelte'
import {
agentResourceDependencies,
aiAgentModuleDependencies,
collectTransformRefs
} from './deployDependencies'
import { base } from '$lib/base'
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
import {
@@ -195,15 +200,7 @@
return getAllModules(flow.value.modules, flow.value.failure_module).flatMap((x) => {
let result: { kind: Kind; path: string }[] = []
if (x.value.type == 'script' || x.value.type == 'rawscript' || x.value.type == 'flow') {
Object.values(x.value.input_transforms).forEach((y) => {
if (y.type == 'static' && typeof y.value == 'string') {
if (y.value.startsWith('$res:')) {
result.push({ kind: 'resource', path: y.value.substring(5) })
} else if (y.value.startsWith('$var:')) {
result.push({ kind: 'variable', path: y.value.substring(5) })
}
}
})
result.push(...collectTransformRefs(x.value.input_transforms))
}
if (x.value.type == 'script') {
if (x.value.path && !x.value.path.startsWith('hub/')) {
@@ -213,6 +210,8 @@
if (x.value.path) {
result.push({ kind: 'flow', path: x.value.path })
}
} else if (x.value.type == 'aiagent') {
result.push(...aiAgentModuleDependencies(x.value))
}
return result
})
@@ -246,17 +245,28 @@
return result
} else if (kind == 'resource') {
const res = await ResourceService.getResource({ workspace: $workspaceStore!, path })
function recObj(obj: any) {
if (typeof obj == 'string' && obj.startsWith('$var:')) {
return [{ kind: 'variable', path: obj.substring(5) }]
} else if (typeof obj == 'object') {
function recObj(obj: any): { kind: Kind; path: string }[] {
if (typeof obj == 'string') {
if (obj.startsWith('$var:')) {
return [{ kind: 'variable', path: obj.substring(5) }]
} else if (obj.startsWith('$jsonvar:')) {
return [{ kind: 'variable', path: obj.substring(9) }]
} else if (obj.startsWith('$res:')) {
return [{ kind: 'resource', path: obj.substring(5) }]
}
return []
} else if (typeof obj == 'object' && obj != null) {
return Object.values(obj).flatMap((x) => recObj(x))
} else {
return []
}
}
return [...recObj(res.value), { kind: 'resource_type', path: res.resource_type }]
return [
...recObj(res.value),
...(res.resource_type == 'ai_agent' ? agentResourceDependencies(res.value) : []),
{ kind: 'resource_type' as Kind, path: res.resource_type }
]
} else if (kind == 'trigger') {
if (additionalInformation?.triggers) {
return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!)
@@ -10,6 +10,13 @@
type Job
} from '$lib/gen'
import { initHistory, redo, undo } from '$lib/history.svelte'
import {
clearLinkedAgentTools,
linkedAgentToolsForScope,
linkedToolsScope,
linkedAgentToolsVersion,
migrateLinkedAgentToolsScope
} from '$lib/components/flows/linkedAgentToolsStore.svelte'
import {
enterpriseLicense,
userStore,
@@ -43,6 +50,11 @@
import WorkspaceScriptSettingsDrawer from './flows/content/WorkspaceScriptSettingsDrawer.svelte'
import FlowEditorDrawer from './flows/content/FlowEditorDrawer.svelte'
import { dfs as dfsApply } from './flows/dfs'
import {
claimLinkedToolsFetch,
invalidateLinkedToolsFetches,
publishLinkedAgentTools
} from './flows/flowState'
import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte'
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
@@ -548,6 +560,136 @@
const history = initHistory(untrack(() => flowStore).val)
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
// Linked-agent tool resolutions are scoped by workspace + flow path, but publishers key by the
// flow doc's own path while readers use the live-edited $pathStore — which diverge for renames
// and renamed drafts. Sweep the doc-path bucket into the live scope on every rename and every
// publish (initFlowState re-runs on session-draft sync and republishes under the doc path).
let prevLinkedToolsScope = untrack(() => linkedToolsScope(opWorkspace, $pathStore))
$effect(() => {
linkedAgentToolsVersion()
const scope = linkedToolsScope(opWorkspace, $pathStore)
const docScope = linkedToolsScope(
opWorkspace,
(flowStore.val as { path?: string }).path ?? $pathStore
)
untrack(() => {
if (scope !== prevLinkedToolsScope) {
sweepLinkedToolsScope(prevLinkedToolsScope, scope, opWorkspace, true)
prevLinkedToolsScope = scope
}
if (docScope !== scope) {
sweepLinkedToolsScope(docScope, scope, opWorkspace, false)
}
})
})
// `renamed` distinguishes the two sources. After a rename every fetch still running against the
// old scope is stale by definition, empty bucket or not — its result would land in a scope
// readers have left and be swept forward later. The doc-scope sweep has no such cut-off: fetches
// there belong to the refresh in progress, so it only acts once that scope holds something.
function sweepLinkedToolsScope(
from: string,
to: string,
ws: string | undefined,
renamed: boolean
) {
const sourceHasTools = Object.keys(linkedAgentToolsForScope(from)).length > 0
if (!renamed && !sourceHasTools) {
return
}
invalidateLinkedToolsFetches(from)
if (sourceHasTools) {
migrateLinkedAgentToolsScope(from, to)
}
// Restart what that cancelled: a link still loading has nothing in `to`, whereas one whose
// tools migrated is already current. A link changed in the same tick keeps the old agent's
// tools here, so it is left for the watcher below, which compares links rather than presence.
const resolved = linkedAgentToolsForScope(to)
for (const [moduleId, agentPath] of linkedAgentEntries(linkedAgentRefs)) {
if (resolved[moduleId] === undefined) {
publishLinkedAgentTools(agentPath, ws, to, moduleId)
}
}
}
// Re-resolve linked agents whenever the set of links changes. Wholesale replacements — undo/redo,
// YAML or AI apply, session restore — swap `agent` without re-running initFlowState, and the step
// editor only watches the step it is mounted on, so an unselected step would keep showing (and
// binding against) the previous agent's tools.
let linkedAgentRefs = $derived(
dfsApply(flowStore.val.value?.modules ?? [], (m) => m, { skipToolNodes: true })
.flatMap((m) => {
const value = m?.value as
| { type?: string; agent?: string; tools?: { id: string; value?: unknown }[] }
| undefined
if (value?.type !== 'aiagent') {
return []
}
const refs = value.agent ? [`${m.id}\u0000${value.agent}`] : []
// A linked agent nested as a tool is invisible to the walk above (tool nodes are skipped
// so resource-owned ids can't alias flow modules), yet it has its own store entry under
// the ancestry-qualified key the step editor writes.
for (const tool of value.tools ?? []) {
const nested = tool?.value as { type?: string; agent?: string } | undefined
if (nested?.type === 'aiagent' && nested.agent) {
refs.push(`${m.id}/${tool.id}\u0000${nested.agent}`)
}
}
return refs
})
.filter((x): x is string => x !== undefined)
.join('\u0001')
)
// The link each module's stored tools belong to. Seeded with the top-level links only, because
// those are exactly what initFlowState resolves — seeding the whole set would mark a nested
// linked agent as current when nothing has fetched it, and seeding nothing would clear and
// refetch every step on the first run.
let publishedAgentByModule = untrack(() =>
linkedAgentEntries(
dfsApply(flowStore.val.value?.modules ?? [], (m) => m, { skipToolNodes: true })
.map((m) => {
const value = m?.value as { type?: string; agent?: string } | undefined
return value?.type === 'aiagent' && value.agent
? `${m.id}\u0000${value.agent}`
: undefined
})
.filter((x): x is string => x !== undefined)
.join('\u0001')
)
)
$effect(() => {
const refs = linkedAgentRefs
const ws = opWorkspace
const scope = linkedToolsScope(opWorkspace, $pathStore)
untrack(() => {
const next = linkedAgentEntries(refs)
// Only links that actually changed are re-resolved, so a plain rename costs nothing: the
// sweep above carries its buckets to the new scope and every entry compares equal. A
// restore that renames and relinks in one tick still gets both.
for (const [moduleId, agentPath] of next) {
if (publishedAgentByModule.get(moduleId) === agentPath) {
continue
}
// Drop the previous agent's tools up front: the fetch below lands later, and until it
// does the graph and the step's binding editor would otherwise still be showing — and
// writing overrides against — the tool ids of the agent that was just replaced.
claimLinkedToolsFetch(scope, moduleId)
clearLinkedAgentTools(scope, moduleId)
publishLinkedAgentTools(agentPath, ws, scope, moduleId)
}
publishedAgentByModule = next
})
})
function linkedAgentEntries(refs: string): Map<string, string> {
const entries = new Map<string, string>()
for (const entry of refs ? refs.split('\u0001') : []) {
const [moduleId, agentPath] = entry.split('\u0000')
entries.set(moduleId, agentPath)
}
return entries
}
// "Open in AI session" target: the URL draft path the editor loads/saves by
// (which for a new flow differs from the live-edited friendly `$pathStore`),
// falling back to `$pathStore` in drawer mounts that carry no storage path.
@@ -11,6 +11,9 @@
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { dfs } from './flows/dfs'
import { workspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
import { publishLinkedAgentTools } from './flows/flowState'
import { linkedToolsScope } from './flows/linkedAgentToolsStore.svelte'
interface Props {
flow: {
@@ -63,6 +66,25 @@
}
const dispatch = createEventDispatcher()
// This read-only viewer doesn't run initFlowState, so linked agents' tools would otherwise never
// resolve. Resolve them for display, keyed by module id. Best-effort: publishLinkedAgentTools
// swallows access errors and publishes [], so an inaccessible agent simply shows no tool nodes
// (its label still names the link) — this never affects a run.
$effect(() => {
// Flow modules only: resource-imported tool ids are not flow-global, so publishing a nested
// linked agent under its bare id would supersede a top-level step that happens to share it.
const modules = dfs(flow?.value?.modules ?? [], (m) => m, { skipToolNodes: true })
const ws = workspace
untrack(() => {
for (const m of modules) {
const value = m?.value as { type?: string; agent?: string } | undefined
if (value?.type === 'aiagent' && value.agent) {
publishLinkedAgentTools(value.agent, ws, linkedToolsScope(ws, flow?.path), m.id)
}
}
})
})
</script>
<div bind:clientHeight={availableHeight} class="grid grid-cols-3 w-full h-full min-h-0">
@@ -113,7 +135,7 @@
noGraph ? 'border-0 w-max' : ''
)}
>
<FlowGraphViewerStep schema={flow?.schema} {stepDetail} {hideDefaultInputs} />
<FlowGraphViewerStep schema={flow?.schema} {stepDetail} {hideDefaultInputs} {workspace} />
</div>
{/if}
</div>
@@ -4,6 +4,7 @@
import IconedPath from './IconedPath.svelte'
import type { FlowModule } from '$lib/gen'
import { Badge, Button, Drawer, DrawerContent } from './common'
import { Link } from 'lucide-svelte'
import { Highlight } from 'svelte-highlight'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import typescript from 'svelte-highlight/languages/typescript'
@@ -11,7 +12,7 @@
import SchemaViewer from './SchemaViewer.svelte'
import { scriptPathToHref } from '$lib/scripts'
import { cleanExpr, copyToClipboard } from '$lib/utils'
import { hubBaseUrlStore } from '$lib/stores'
import { hubBaseUrlStore, workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import FlowModuleScript from './flows/content/FlowModuleScript.svelte'
@@ -24,9 +25,19 @@
stepDetail?: FlowModule | string | undefined
jobScriptHash?: string | undefined
hideDefaultInputs?: boolean
// The workspace the viewed flow belongs to (differs from the nav workspace in fork/session
// editors); used to qualify resource links.
workspace?: string
}
let { schema = undefined, stepDetail = undefined, jobScriptHash = undefined, hideDefaultInputs = false }: Props = $props()
let {
schema = undefined,
stepDetail = undefined,
jobScriptHash = undefined,
hideDefaultInputs = false,
workspace = undefined
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let codeViewer: Drawer | undefined = $state()
</script>
@@ -86,9 +97,7 @@
<div class={twMerge('p-2 overflow-y-scroll')}>
{#if stepDetail == undefined}
<div>
<p class="text-secondary text-xs italic px-2 pt-2">
Click on a step to see its details
</p>
<p class="text-secondary text-xs italic px-2 pt-2"> Click on a step to see its details </p>
{#if schema && !hideDefaultInputs}
<h3 class="mb-2 font-semibold">Flow Inputs</h3>
<SchemaViewer {schema} />
@@ -223,6 +232,17 @@
<FlowModuleScript path={stepDetail.value.path} hash={jobScriptHash} />
{:else if stepDetail.value.type == 'aiagent'}
<div class="text-xs">
{#if stepDetail.value.agent}
<div class="mb-1 flex items-center gap-1.5 text-2xs text-secondary">
<Link size={12} class="shrink-0" />
<span class="shrink-0">Linked to</span>
<a
class="truncate font-medium"
href={`/resources?path=${stepDetail.value.agent}&workspace=${ws}`}
title={stepDetail.value.agent}>{stepDetail.value.agent}</a
>
</div>
{/if}
<h3 class="mb-1 font-semibold mt-2 text-xs text-emphasis">Step inputs</h3>
<InputTransformsViewer inputTransforms={stepDetail?.value?.input_transforms ?? {}} />
</div>
@@ -61,6 +61,13 @@
import { useThrottle } from 'runed'
import { Splitpanes, Pane } from 'svelte-splitpanes'
import { getActiveReplay } from './recording/flowRecording.svelte'
import { publishLinkedAgentTools } from './flows/flowState'
import {
getLinkedAgentTools,
linkedToolsScope,
releaseLinkedToolsScope,
retainLinkedToolsScope
} from './flows/linkedAgentToolsStore.svelte'
let {
flowState: flowStateStore,
@@ -248,6 +255,48 @@
})
})
// A linked agent persists no tools on the module, so this read-only viewer resolves them from the
// resource. Keyed by job rather than flow path: it also renders in the editor's preview pane,
// where sharing the editor's `${ws}:${flow path}` bucket would let an older run's agent overwrite
// the tool nodes of the flow being edited.
let linkedToolsViewScope = $derived(linkedToolsScope(workspace, `job:${job?.id ?? ''}`))
// Flattened to a string so polling a running flow — which replaces `job` every tick — only
// re-fetches when the set of linked steps actually changes.
let linkedAgentRefs = $derived(
// Flow modules only: resource-imported tool ids are not flow-global, so publishing a nested
// linked agent under its bare id would supersede a top-level step that happens to share it.
dfs(job?.raw_flow?.modules ?? [], (m) => m, { skipToolNodes: true })
.map((m) => {
const value = m?.value as { type?: string; agent?: string } | undefined
return value?.type === 'aiagent' && value.agent ? `${m.id}\u0000${value.agent}` : undefined
})
.filter((x): x is string => x !== undefined)
.join('\u0001')
)
// Session and fork previews render this viewer against another workspace, passed as `workspaceId`;
// resolving in the navigation one finds nothing, or an unrelated resource sharing the path. The
// store scope stays keyed on `workspace` to match what FlowGraphV2 reads — the job id in the key
// already makes the bucket unique.
let agentFetchWorkspace = $derived(workspaceId ?? job?.workspace_id ?? $workspaceStore)
// Hold this scope for as long as the viewer is mounted, so the store's cap can't drop tools the
// run still needs (nothing would refetch them — the set of linked steps hasn't changed).
$effect(() => {
const scope = linkedToolsViewScope
retainLinkedToolsScope(scope)
return () => releaseLinkedToolsScope(scope)
})
$effect(() => {
const refs = linkedAgentRefs
const ws = agentFetchWorkspace
const scope = linkedToolsViewScope
untrack(() => {
for (const entry of refs ? refs.split('\u0001') : []) {
const [moduleId, agentPath] = entry.split('\u0000')
publishLinkedAgentTools(agentPath, ws, scope, moduleId)
}
})
})
let jobResults: any[] = $state(
untrack(() => flowJobIds)?.flowJobs?.map(
(x, id) => `iter #${id + 1} not loaded by frontend yet`
@@ -1971,6 +2020,7 @@
earlyStop={job.raw_flow?.skip_expr !== undefined}
cache={job.raw_flow?.cache_ttl !== undefined}
modules={job.raw_flow?.modules ?? []}
linkedToolsPath={`job:${job?.id ?? ''}`}
notes={notesProp ?? job.raw_flow?.notes ?? []}
groups={groupsProp ?? job.raw_flow?.groups}
failureModule={job.raw_flow?.failure_module}
@@ -2094,7 +2144,9 @@
stepDetail && typeof stepDetail !== 'string' ? stepDetail : undefined}
{@const agentTools =
module && module.value.type === 'aiagent'
? module.value.tools
? module.value.agent
? getLinkedAgentTools(linkedToolsViewScope, module.id)
: (module.value.tools ?? [])
: undefined}
{@const parentLoopsPrefix = getParentLoopsPrefix(module?.id ?? '')}
{#if node.flow_jobs_results}
@@ -15,6 +15,9 @@
disablePortal?: boolean
expressOAuthSetup?: boolean
disabled?: boolean
// Overrides the workspace to list/connect resources in (e.g. a fork/session editor's
// operating workspace). Falls back to the app-viewer context then the nav workspace.
workspace?: string | undefined
}
let {
@@ -22,12 +25,14 @@
resourceType = undefined,
disablePortal = false,
expressOAuthSetup = false,
disabled = false
disabled = false,
workspace = undefined
}: Props = $props()
let open = $state(false)
let refreshCount = $state(0)
const appViewerContext = getContext<AppViewerContext>('AppViewerContext')
let ws = $derived(workspace ?? appViewerContext?.workspace ?? $workspaceStore)
let collection = $state(value ? [{ value, label: value }] : [])
@@ -37,7 +42,7 @@
try {
const nc = (
await ResourceService.listResource({
workspace: appViewerContext?.workspace ?? $workspaceStore,
workspace: ws,
resourceType
})
).map((x) => ({
@@ -59,7 +64,7 @@
}
$effect(() => {
$workspaceStore && resourceType && untrack(() => loadResources(resourceType))
ws && resourceType && untrack(() => loadResources(resourceType))
})
let darkMode: boolean = $state(false)
@@ -79,7 +84,7 @@
{#key refreshCount}
{#await import('./AppConnectLightweightResourcePicker.svelte') then Module}
<Module.default
workspace={appViewerContext?.workspace ?? $workspaceStore}
workspace={ws}
{resourceType}
express={true}
on:error={(e) => {
@@ -107,7 +112,7 @@
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
workspace={appViewerContext?.workspace ?? $workspaceStore}
workspace={ws}
{resourceType}
express={false}
on:error={(e) => {
@@ -186,7 +191,7 @@
on:click={async () => {
if (value) {
await ResourceService.deleteResource({
workspace: appViewerContext?.workspace ?? $workspaceStore,
workspace: ws,
path: value
})
value = undefined
@@ -8,6 +8,7 @@
import OutputPickerInner from '$lib/components/flows/propPicker/OutputPickerInner.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import type { FlowEditorContext, OutputViewerJob } from './flows/types'
import type { AgentTool } from './flows/agentToolUtils'
import { getContext } from 'svelte'
import { getStringError } from './copilot/chat/utils'
import AiAgentLogViewer from './AIAgentLogViewer.svelte'
@@ -26,6 +27,9 @@
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
loadingJob?: boolean
tagLabel?: string
// A linked agent persists no tools of its own; its resolved resource tools are passed here so
// the log viewer can label each tool_call with the definition that ran.
linkedAgentTools?: AgentTool[]
}
let {
@@ -41,7 +45,8 @@
disableHistory = false,
onUpdateMock,
loadingJob = false,
tagLabel = undefined
tagLabel = undefined,
linkedAgentTools = undefined
}: Props = $props()
const { stepsInputArgs, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -98,7 +103,7 @@
/>
{:else if mod.value.type === 'aiagent' && logJob?.type === 'CompletedJob'}
<AiAgentLogViewer
tools={mod.value.tools}
tools={linkedAgentTools ?? mod.value.tools ?? []}
agentJob={{
...logJob,
type: 'CompletedJob'
@@ -137,6 +137,8 @@
])
)
const agentVal = val
await jobLoader?.runFlowPreview(
args,
{
@@ -144,11 +146,16 @@
modules: [
{
id: mod.id,
// A linked step has no tools of its own: the resource's tools are resolved
// server-side from `agent`. `tool_inputs` goes in either way — a step forked
// for editing has no `agent` yet still carries the flow's bindings, which the
// runtime overlays, so the preview must test against them too.
value: {
type: 'aiagent',
tools: mod.value.type == 'aiagent' ? mod.value.tools : [],
...(agentVal.agent ? { agent: agentVal.agent } : { tools: agentVal.tools ?? [] }),
tool_inputs: agentVal.tool_inputs,
input_transforms: inputTransforms as AiAgent['input_transforms']
}
} as Extract<FlowModule['value'], { type: 'aiagent' }>
}
]
},
@@ -1,6 +1,7 @@
import { z } from 'zod'
import type { FlowModule, FlowValue } from '$lib/gen'
import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree'
import { collectProviderlessAgentIds } from '$lib/components/flows/agentToolTree'
import { SPECIAL_MODULE_IDS } from '../shared'
import { findUnresolvedInlineScriptRefs, type InlineScriptSession } from './inlineScriptsUtils'
import {
@@ -242,9 +243,23 @@ export function validateFlowModules(
)
}
// Not expressible in the schema: `provider` is required only when the step is standalone, and
// making AiAgent a conditional union breaks the FlowModuleValue discriminated union it belongs to.
const providerless = collectProviderlessAgentIds(parsedModules)
if (providerless.length > 0) {
throw new Error(
`AI agent modules ${providerless
.map((id) => `"${id}"`)
.join(
', '
)} need a provider input transform, or an "agent" path linking them to a saved agent`
)
}
return parsedModules
}
export function validateFlowSchema(rawSchema: unknown): Record<string, any> | null {
if (rawSchema == null) return null
if (typeof rawSchema !== 'object' || Array.isArray(rawSchema)) {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import { agentResourceDependencies, aiAgentModuleDependencies } from './deployDependencies'
const res = (path: string) => ({ type: 'static', value: `$res:${path}` })
describe('aiAgentModuleDependencies', () => {
it('queues the linked agent resource', () => {
expect(aiAgentModuleDependencies({ agent: '$res:f/team/my_agent' })).toEqual([
{ kind: 'resource', path: 'f/team/my_agent' }
])
})
// A tool_inputs override replaces the resource tool's default at runtime, so the target
// workspace needs the overriding value, not the saved default.
it('follows tool_inputs overrides, which shadow the saved defaults', () => {
expect(
aiAgentModuleDependencies({
agent: 'f/team/my_agent',
tool_inputs: {
fetch: { db: res('f/prod/db'), token: { type: 'static', value: '$var:f/prod/tok' } },
other: { expr: { type: 'javascript', expr: 'flow_input.x' } }
}
})
).toEqual([
{ kind: 'resource', path: 'f/team/my_agent' },
{ kind: 'resource', path: 'f/prod/db' },
{ kind: 'variable', path: 'f/prod/tok' }
])
})
})
describe('agentResourceDependencies', () => {
it('collects a saved agent tools bare paths and transform refs', () => {
expect(
agentResourceDependencies({
tools: [
{
id: 'a',
value: {
type: 'script',
path: 'f/lib/tool',
input_transforms: { db: res('f/prod/db') }
}
},
{ id: 'b', value: { type: 'script', path: 'hub/1/thing' } },
{ id: 'c', value: { tool_type: 'mcp', resource_path: '$res:f/mcp/server' } }
]
})
).toEqual([
{ kind: 'resource', path: 'f/prod/db' },
{ kind: 'script', path: 'f/lib/tool' },
{ kind: 'resource', path: 'f/mcp/server' }
])
})
it('recurses into an inline nested agent but links a nested one', () => {
expect(
agentResourceDependencies({
tools: [
{
id: 'inline',
value: {
type: 'aiagent',
tools: [{ id: 'x', value: { type: 'flow', path: 'f/lib/sub' } }]
}
},
{ id: 'linked', value: { type: 'aiagent', agent: 'f/team/other' } }
]
})
).toEqual([
{ kind: 'flow', path: 'f/lib/sub' },
{ kind: 'resource', path: 'f/team/other' }
])
})
})
describe('inline agent dependencies', () => {
// A provider is an object-valued static transform holding its credential under `resource`, so a
// top-level string check misses it.
it('finds a provider credential nested in an object-valued transform', () => {
expect(
aiAgentModuleDependencies({
input_transforms: {
provider: { type: 'static', value: { kind: 'openai', model: 'gpt-4o' } }
},
tools: []
})
).toEqual([])
expect(
aiAgentModuleDependencies({
input_transforms: {
provider: {
type: 'static',
value: { kind: 'openai', model: 'gpt-4o', resource: '$res:f/ai/openai' }
}
},
tools: []
})
).toEqual([{ kind: 'resource', path: 'f/ai/openai' }])
})
// The worker resolves $jsonvar: alongside $var:/$res:, so a secret referenced only that way has
// to deploy with the agent or the linked step fails in the target workspace.
it('collects $jsonvar: references', () => {
expect(
aiAgentModuleDependencies({
input_transforms: { key: { type: 'static', value: '$jsonvar:f/prod/cfg' } },
tools: []
})
).toEqual([{ kind: 'variable', path: 'f/prod/cfg' }])
})
it('walks an inline agent own tools, which the flow module walk does not reach', () => {
expect(
aiAgentModuleDependencies({
tools: [{ id: 'm', value: { tool_type: 'mcp', resource_path: 'f/mcp/server' } }]
})
).toEqual([{ kind: 'resource', path: 'f/mcp/server' }])
})
})
@@ -0,0 +1,79 @@
import type { Kind } from '$lib/utils_deployable'
export type Dependency = { kind: Kind; path: string }
export function stripResourcePrefix(ref: string): string {
return ref.replace(/^\$res:/, '').replace(/^res:\/\//, '')
}
/** Workspace objects referenced by an input_transforms map through a static `$res:`/`$var:` value.
* The value is walked, not just string-matched: an AI agent's provider is an object holding its
* credential under `resource`, so a top-level string check alone misses it. */
export function collectTransformRefs(transforms: unknown): Dependency[] {
const result: Dependency[] = []
const walk = (v: unknown) => {
if (typeof v == 'string') {
if (v.startsWith('$res:')) {
result.push({ kind: 'resource', path: v.substring(5) })
} else if (v.startsWith('$var:')) {
result.push({ kind: 'variable', path: v.substring(5) })
} else if (v.startsWith('$jsonvar:')) {
result.push({ kind: 'variable', path: v.substring(9) })
}
} else if (typeof v == 'object' && v != null) {
for (const inner of Object.values(v)) walk(inner)
}
}
for (const t of Object.values((transforms ?? {}) as Record<string, any>)) {
if (t?.type == 'static') {
walk(t.value)
}
}
return result
}
/** A saved agent bundles its tools, which reference workspace objects by bare path rather than
* `$res:` invisible to the generic value walk. A nested *linked* agent is queued as a resource and
* recursed into; a nested *inline* one carries its tools here, so recurse into them too. */
export function agentResourceDependencies(value: unknown): Dependency[] {
const result: Dependency[] = []
for (const tool of ((value as any)?.tools ?? []) as any[]) {
const v = tool?.value
if (typeof v !== 'object' || v == null) continue
result.push(...collectTransformRefs(v.input_transforms))
if (typeof v.resource_path == 'string' && v.resource_path) {
result.push({ kind: 'resource', path: stripResourcePrefix(v.resource_path) })
} else if (v.type == 'script' && typeof v.path == 'string' && v.path) {
if (!v.path.startsWith('hub/')) {
result.push({ kind: 'script', path: v.path })
}
} else if (v.type == 'flow' && typeof v.path == 'string' && v.path) {
result.push({ kind: 'flow', path: v.path })
} else if (v.type == 'aiagent') {
if (typeof v.agent == 'string' && v.agent) {
result.push({ kind: 'resource', path: stripResourcePrefix(v.agent) })
} else {
result.push(...agentResourceDependencies(v))
}
}
}
return result
}
/** An AI agent step's own dependencies. Linked: the saved agent, plus the flow-local `tool_inputs`
* overrides an override replaces the resource tool's default at runtime, so the value this flow
* actually uses must follow the deploy. Standalone: its inline tools, which the flow module walk
* only partly reaches (MCP and websearch tools are not flow modules). */
export function aiAgentModuleDependencies(value: unknown): Dependency[] {
const v = value as any
const result: Dependency[] = collectTransformRefs(v?.input_transforms)
if (typeof v?.agent == 'string' && v.agent) {
result.push({ kind: 'resource', path: stripResourcePrefix(v.agent) })
} else {
result.push(...agentResourceDependencies(v))
}
for (const overrides of Object.values((v?.tool_inputs ?? {}) as Record<string, unknown>)) {
result.push(...collectTransformRefs(overrides))
}
return result
}
@@ -0,0 +1,78 @@
// The path an AI agent step is being edited-in-place against (set by "Edit" on a linked agent).
// Lives outside AgentResourceBar so the "Editing <path>" mode survives that component unmounting
// when another node is selected.
//
// Entries are looked up by the forked step's `tools` array identity, not by any location key:
// Edit assigns a fresh array (unique per fork, including nested editors reusing a module id),
// in-place edits keep it, and wholesale state replacement (undo, session drafts) yields new
// objects that simply never match — a stale entry can't resurface as a phantom Editing banner.
import type { FlowModule } from '$lib/gen'
type Entry = { path: string; marker: object }
// Capped: abandoned forks (editor closed without Save/Cancel) leave dead entries behind.
const MAX_ENTRIES = 20
let entries = $state<Entry[]>([])
export function getAgentEditingPath(marker: object | undefined): string | undefined {
return marker ? entries.find((e) => e.marker === marker)?.path : undefined
}
export function setAgentEditingPath(marker: object | undefined, path: string | undefined) {
if (!marker) return
const rest = entries.filter((e) => e.marker !== marker)
entries = path ? [...rest.slice(-(MAX_ENTRIES - 1)), { marker, path }] : rest
}
// Carry edit state across a wholesale clone of the flow: the clone replaces every `tools` array
// (breaking marker identity) while preserving content verbatim, so re-keying entries to the
// cloned arrays by module id is safe there — unlike undo or session restores, which change
// content and must keep invalidating.
export function reanchorAgentEditsAcross(
getModules: () => FlowModule[] | undefined,
refresh: () => void
) {
const anchors: { key: string; path: string; marker: object }[] = []
forEachAgentFork(getModules(), (key, tools) => {
const path = getAgentEditingPath(tools)
if (path) anchors.push({ key, path, marker: tools })
})
refresh()
if (anchors.length === 0) return
const byKey = new Map<string, object>()
forEachAgentFork(getModules(), (key, tools) => byKey.set(key, tools))
for (const a of anchors) {
setAgentEditingPath(a.marker, undefined)
const tools = byKey.get(a.key)
if (tools) setAgentEditingPath(tools, a.path)
}
}
// Visit every standalone (unlinked) agent's tools array, including nested agent tools. Keys are
// ancestry-qualified (`hostId/toolId`): resource-imported tool ids are not flow-global, so a
// nested agent may share a bare id with a flow module and must not alias it.
function forEachAgentFork(
modules: FlowModule[] | undefined,
visit: (key: string, tools: object) => void,
prefix: string = ''
) {
for (const mod of modules ?? []) {
const v = mod.value as FlowModule['value'] | undefined
if (!v) continue
if (v.type === 'forloopflow' || v.type === 'whileloopflow') {
forEachAgentFork(v.modules, visit, prefix)
} else if (v.type === 'branchone') {
forEachAgentFork(v.default, visit, prefix)
for (const b of v.branches ?? []) forEachAgentFork(b.modules, visit, prefix)
} else if (v.type === 'branchall') {
for (const b of v.branches ?? []) forEachAgentFork(b.modules, visit, prefix)
} else if (v.type === 'aiagent') {
const key = `${prefix}${mod.id}`
if (!v.agent && v.tools) {
visit(key, v.tools)
}
forEachAgentFork(v.tools as FlowModule[] | undefined, visit, `${key}/`)
}
}
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import type { FlowModule } from '$lib/gen'
import {
getAgentEditingPath,
setAgentEditingPath,
reanchorAgentEditsAcross
} from './agentEditStore.svelte'
function tool(id: string): object {
return { id, value: { type: 'rawscript', tool_type: 'flowmodule', input_transforms: {} } }
}
function agentFork(id: string, tools: object[]): FlowModule {
return { id, value: { type: 'aiagent', tools, input_transforms: {} } } as unknown as FlowModule
}
describe('reanchorAgentEditsAcross', () => {
it('re-keys edit entries to the cloned tools arrays by module id', () => {
const toolsA: object[] = [tool('t1')]
const toolsB: object[] = [tool('t2')]
let modules = [agentFork('a', toolsA), agentFork('b', toolsB)]
setAgentEditingPath(toolsA, 'f/agents/one')
setAgentEditingPath(toolsB, 'f/agents/two')
reanchorAgentEditsAcross(
() => modules,
() => {
// stand-in for refreshStateStore's $state.snapshot deep clone
modules = JSON.parse(JSON.stringify(modules))
}
)
expect(getAgentEditingPath(toolsA)).toBeUndefined()
expect(getAgentEditingPath((modules[0].value as any).tools)).toBe('f/agents/one')
expect(getAgentEditingPath((modules[1].value as any).tools)).toBe('f/agents/two')
})
it('keeps host and nested-agent entries distinct when a nested tool reuses the host id', () => {
// Resource tool ids are not flow-global: a nested agent tool may share the host step's id.
const nestedTools: object[] = [tool('t2')]
const nested = agentFork('a', nestedTools)
const hostTools: object[] = [tool('t1'), nested]
let modules = [agentFork('a', hostTools)]
setAgentEditingPath(hostTools, 'f/agents/parent')
setAgentEditingPath(nestedTools, 'f/agents/nested')
reanchorAgentEditsAcross(
() => modules,
() => {
modules = JSON.parse(JSON.stringify(modules))
}
)
const newHost = (modules[0].value as any).tools
const newNested = newHost[1].value.tools
expect(getAgentEditingPath(newHost)).toBe('f/agents/parent')
expect(getAgentEditingPath(newNested)).toBe('f/agents/nested')
})
it('drops the entry when the module is gone after the refresh', () => {
const tools: object[] = [tool('t1')]
let modules = [agentFork('a', tools)]
setAgentEditingPath(tools, 'f/agents/one')
reanchorAgentEditsAcross(
() => modules,
() => {
modules = []
}
)
expect(getAgentEditingPath(tools)).toBeUndefined()
})
})
@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import {
agentConfigToInputTransforms,
flowLocalInputs,
inputTransformsToAgentConfig,
nonStaticBrainKeys,
summarizeAgentBrain,
toolInputOverrides
} from './agentResourceUtils'
describe('summarizeAgentBrain', () => {
it('returns only set fields, in brain-key order, formatted', () => {
const rows = summarizeAgentBrain({
provider: { kind: 'openai', model: 'gpt-4o', resource: '$res:f/x/openai' } as any,
system_prompt: 'You are helpful',
temperature: 0.7,
streaming: true,
max_iterations: 10
})
expect(rows).toEqual([
{ label: 'Model', value: 'openai · gpt-4o' },
{ label: 'System prompt', value: 'You are helpful' },
{ label: 'Streaming', value: 'on' },
{ label: 'Temperature', value: '0.7' },
{ label: 'Max iterations', value: '10' }
])
})
it('skips empty/undefined fields', () => {
expect(summarizeAgentBrain({ system_prompt: '', provider: undefined as any })).toEqual([])
expect(summarizeAgentBrain(undefined)).toEqual([])
})
it('summarizes structured fields compactly', () => {
// memory is serialized with a `kind` tag (serde tag = "kind")
const rows = summarizeAgentBrain({
memory: { kind: 'auto', context_length: 20 } as any,
output_schema: { type: 'object' } as any
})
expect(rows).toEqual([
{ label: 'Memory', value: 'auto' },
{ label: 'Output schema', value: 'configured' }
])
})
})
describe('inputTransformsToAgentConfig', () => {
it('captures only static brain values and carries tools', () => {
const config = inputTransformsToAgentConfig(
{
provider: { type: 'static', value: { kind: 'openai' } },
system_prompt: { type: 'static', value: 'hi' },
temperature: { type: 'javascript', expr: 'flow_input.t' }, // non-static → dropped
max_iterations: { type: 'static', value: undefined }, // undefined → dropped
user_message: { type: 'static', value: 'hello' } // not a brain key → dropped
} as any,
[{ id: 't1' }] as any
)
expect(config).toEqual({
tools: [{ id: 't1' }],
provider: { kind: 'openai' },
system_prompt: 'hi'
})
})
it('defaults tools to []', () => {
expect(inputTransformsToAgentConfig({}, undefined)).toEqual({ tools: [] })
})
})
describe('agentConfigToInputTransforms', () => {
it('wraps brain values as static transforms and round-trips through the config', () => {
const config = { provider: { kind: 'openai' }, system_prompt: 'hi', tools: [{ id: 't1' }] }
const its = agentConfigToInputTransforms(config as any)
expect(its).toEqual({
provider: { type: 'static', value: { kind: 'openai' } },
system_prompt: { type: 'static', value: 'hi' }
})
const back = inputTransformsToAgentConfig(its, config.tools as any)
expect(back).toEqual(config)
})
})
describe('nonStaticBrainKeys', () => {
it('lists brain keys with a non-static transform, in brain-key order', () => {
expect(
nonStaticBrainKeys({
provider: { type: 'static', value: {} },
temperature: { type: 'connected' },
system_prompt: { type: 'javascript', expr: 'x' }
} as any)
).toEqual(['system_prompt', 'temperature'])
})
it('flags a non-static provider (the save-blocking case) but not a static one', () => {
// provider is required on the resource; a non-static one gets dropped and must block saving
expect(nonStaticBrainKeys({ provider: { type: 'javascript', expr: 'x' } } as any)).toContain(
'provider'
)
expect(nonStaticBrainKeys({ provider: { type: 'static', value: {} } } as any)).not.toContain(
'provider'
)
})
})
describe('flowLocalInputs', () => {
it('keeps only user_message/user_attachments, dropping brain transforms', () => {
expect(
flowLocalInputs({
provider: { type: 'static', value: {} },
user_message: { type: 'static', value: 'hi' },
user_attachments: { type: 'static', value: [] }
} as any)
).toEqual({
user_message: { type: 'static', value: 'hi' },
user_attachments: { type: 'static', value: [] }
})
})
it('handles undefined', () => {
expect(flowLocalInputs(undefined)).toEqual({})
})
})
describe('toolInputOverrides', () => {
const base = {
tenant: { type: 'javascript', expr: 'flow_input.tenant' },
query: { type: 'static', value: 'x' }
} as any
it('returns nothing when inputs equal the resource base (opening a tool is a no-op)', () => {
expect(toolInputOverrides(base, base)).toEqual({})
})
it('returns only the edited keys, so a revert to base drops back to nothing', () => {
const edited = { ...base, query: { type: 'static', value: 'y' } }
expect(toolInputOverrides(edited, base)).toEqual({ query: { type: 'static', value: 'y' } })
// reverting query back to the base value yields an empty override set again
expect(toolInputOverrides(base, base)).toEqual({})
})
it('includes keys absent from the base', () => {
expect(toolInputOverrides({ extra: { type: 'static', value: 1 } } as any, base)).toEqual({
extra: { type: 'static', value: 1 }
})
})
})
@@ -0,0 +1,172 @@
import { deepEqual } from 'fast-equals'
import type { InputTransform } from '$lib/gen'
// The brain fields stored flat in an `ai_agent` resource value. The flow-local inputs
// (user_message/user_attachments) are intentionally excluded — they are supplied per-flow.
export const AGENT_BRAIN_KEYS = [
'provider',
'output_type',
'system_prompt',
'streaming',
'memory',
'output_schema',
'max_completion_tokens',
'temperature',
'max_iterations'
] as const
export const AGENT_FLOW_LOCAL_KEYS = ['user_message', 'user_attachments'] as const
export type AgentTool = Record<string, any>
/** Brain keys whose step transform is non-static and would be dropped by a save-as-agent snapshot. */
export function nonStaticBrainKeys(
inputTransforms: Record<string, InputTransform> | undefined
): string[] {
return AGENT_BRAIN_KEYS.filter((key) => {
const t = inputTransforms?.[key] as any
return t && t.type !== 'static'
})
}
/**
* Keep only the flow-local inputs on a step's input_transforms. Used when linking: the brain comes
* from the resource, so the step must not carry stale `provider`/`system_prompt`/ transforms at
* runtime they'd still be resolved (an unnecessary, possibly failing provider `$res:` fetch) yet
* never used, since the linked branch takes the brain from the resource.
*/
export function flowLocalInputs(
inputTransforms: Record<string, InputTransform> | undefined
): Record<string, InputTransform> {
const out: Record<string, InputTransform> = {}
for (const key of AGENT_FLOW_LOCAL_KEYS) {
if (inputTransforms?.[key]) {
out[key] = inputTransforms[key]
}
}
return out
}
/**
* The host-flow overrides to store on a linked step for one tool: the subset of the tool's edited
* input_transforms that diverges from the resource tool's own transforms. Storing only the diff (not
* the full merged map) keeps unchanged inputs inheriting from the resource, makes merely opening a
* tool a no-op (its inputs still equal base overrides), and lets reverting an edit persist.
*/
export function toolInputOverrides(
inputs: Record<string, InputTransform> | undefined,
base: Record<string, InputTransform> | undefined
): Record<string, InputTransform> {
const overrides: Record<string, InputTransform> = {}
for (const [key, value] of Object.entries(inputs ?? {})) {
if (!deepEqual(value, base?.[key])) {
overrides[key] = value
}
}
return overrides
}
export interface AIAgentConfig {
provider?: unknown
output_type?: string
system_prompt?: string
streaming?: boolean
memory?: unknown
output_schema?: unknown
max_completion_tokens?: number
temperature?: number
max_iterations?: number
tools?: AgentTool[]
}
/** Extract the static brain values from a step's input_transforms into a flat agent config. */
export function inputTransformsToAgentConfig(
inputTransforms: Record<string, InputTransform> | undefined,
tools: AgentTool[] | undefined
): AIAgentConfig {
const config: AIAgentConfig = { tools: tools ?? [] }
for (const key of AGENT_BRAIN_KEYS) {
const t = inputTransforms?.[key] as any
if (t && t.type === 'static' && t.value !== undefined) {
;(config as any)[key] = t.value
}
}
return config
}
/**
* Reduce the AI agent schema to only the flow-local inputs. Used when a step is linked to a saved
* agent: the brain fields come from the resource, so only user_message/user_attachments stay editable.
*/
export function flowLocalAgentSchema(schema: any): any {
if (!schema?.properties) {
return schema
}
const properties: Record<string, unknown> = {}
for (const key of AGENT_FLOW_LOCAL_KEYS) {
if (schema.properties[key]) {
properties[key] = schema.properties[key]
}
}
return {
...schema,
properties,
order: (schema.order ?? Object.keys(properties)).filter((k: string) => k in properties),
required: (schema.required ?? []).filter((k: string) => k in properties)
}
}
const AGENT_BRAIN_LABELS: Record<string, string> = {
provider: 'Model',
output_type: 'Output type',
system_prompt: 'System prompt',
streaming: 'Streaming',
memory: 'Memory',
output_schema: 'Output schema',
max_completion_tokens: 'Max tokens',
temperature: 'Temperature',
max_iterations: 'Max iterations'
}
/** Flatten a saved agent's brain config into human-readable label/value rows for a read-only
* display on a linked step. Only set fields are returned, in the canonical brain-key order. */
export function summarizeAgentBrain(
config: AIAgentConfig | undefined
): { label: string; value: string }[] {
const rows: { label: string; value: string }[] = []
for (const key of AGENT_BRAIN_KEYS) {
const v = (config as any)?.[key]
if (v === undefined || v === null || v === '') continue
let value: string
if (key === 'provider') {
value = [v.kind, v.model].filter(Boolean).join(' · ') || 'configured'
} else if (key === 'memory') {
// Memory configs are serialized with a `kind` tag (serde tag = "kind").
value = typeof v === 'object' ? (v.kind ?? v.type ?? 'configured') : String(v)
} else if (key === 'output_schema') {
value = 'configured'
} else if (typeof v === 'boolean') {
value = v ? 'on' : 'off'
} else if (typeof v === 'object') {
value = JSON.stringify(v)
} else {
value = String(v)
}
rows.push({ label: AGENT_BRAIN_LABELS[key] ?? key, value })
}
return rows
}
/** Inverse: wrap brain config values as static input_transforms (used when unlinking a step). */
export function agentConfigToInputTransforms(
config: AIAgentConfig
): Record<string, InputTransform> {
const it: Record<string, InputTransform> = {}
for (const key of AGENT_BRAIN_KEYS) {
const v = (config as any)[key]
if (v !== undefined) {
it[key] = { type: 'static', value: v } as InputTransform
}
}
return it
}
@@ -11,6 +11,7 @@ vi.mock('./flowInfers', () => ({
import type { FlowModule } from '$lib/gen'
import {
collectFlowNodeIds,
collectProviderlessAgentIds,
findAgentToolOwner,
removeAgentToolOwner
} from './agentToolTree'
@@ -103,3 +104,37 @@ describe('collectFlowNodeIds', () => {
])
})
})
describe('collectProviderlessAgentIds', () => {
const provider = { type: 'static', value: { kind: 'openai', model: 'gpt-4o' } }
const agent = (id: string, value: Record<string, unknown>) => ({
id,
value: { type: 'aiagent', input_transforms: {}, ...value }
})
it('a linked agent needs no provider of its own', () => {
expect(collectProviderlessAgentIds([agent('a', { agent: 'f/team/my_agent' })])).toEqual([])
})
it('a standalone agent with a provider is fine', () => {
expect(
collectProviderlessAgentIds([agent('a', { input_transforms: { provider }, tools: [] })])
).toEqual([])
})
it('a standalone agent without a provider is reported', () => {
expect(collectProviderlessAgentIds([agent('a', { tools: [] })])).toEqual(['a'])
})
it('nested agent tools and branches are traversed', () => {
const nested = agent('parent', {
input_transforms: { provider },
tools: [agent('nested', { tools: [] })]
})
expect(
collectProviderlessAgentIds([
{ id: 'b', value: { type: 'branchall', branches: [{ modules: [nested] }] } }
])
).toEqual(['nested'])
})
})
@@ -96,18 +96,20 @@ function findAgentToolOwnerInNode(
return undefined
}
const toolIndex = node.value.tools.findIndex((tool) => tool.id === toolId)
// Absent for a linked agent, whose tools live in the resource rather than on the module.
const tools = node.value.tools ?? []
const toolIndex = tools.findIndex((tool) => tool.id === toolId)
if (toolIndex !== -1) {
return {
agentId: node.id,
tools: node.value.tools,
tools,
toolIndex,
tool: node.value.tools[toolIndex],
tool: tools[toolIndex],
depth: depth + 1
}
}
for (const tool of node.value.tools) {
for (const tool of tools) {
if (!isFlowModuleTool(tool)) {
continue
}
@@ -153,10 +155,38 @@ function collectFlowNodeIdsFromNode(node: FlowNodeLike): string[] {
}
if (node.value.type === 'aiagent') {
for (const tool of node.value.tools) {
for (const tool of node.value.tools ?? []) {
ids.push(...collectAgentToolIds(tool))
}
}
return ids
}
/** Ids of AI agent modules that neither link to a saved agent nor set a provider they pass schema
* validation (a linked step legitimately has no provider of its own) but fail on every run. */
export function collectProviderlessAgentIds(modules: unknown): string[] {
const ids: string[] = []
const visit = (mods: unknown) => {
if (!Array.isArray(mods)) return
for (const mod of mods) {
const v = (mod as FlowModule | undefined)?.value as Record<string, any> | undefined
if (!v) continue
if (v.type === 'aiagent') {
if (!v.agent && !v.input_transforms?.provider) {
ids.push((mod as FlowModule).id)
}
visit(v.tools)
} else if (v.type === 'forloopflow' || v.type === 'whileloopflow') {
visit(v.modules)
} else if (v.type === 'branchone') {
visit(v.default)
for (const b of v.branches ?? []) visit(b.modules)
} else if (v.type === 'branchall') {
for (const b of v.branches ?? []) visit(b.modules)
}
}
}
visit(modules)
return ids
}
@@ -6,7 +6,7 @@ export const SPECIAL_TOOL_KINDS = ['mcpTool', 'websearchTool', 'aiAgentTool'] as
export type SpecialToolKind = (typeof SPECIAL_TOOL_KINDS)[number]
// Type aliases for better readability
export type AgentTool = AiAgent['tools'][number]
export type AgentTool = NonNullable<AiAgent['tools']>[number]
export type FlowModuleTool = AgentTool & { value: { tool_type: 'flowmodule' } & FlowModuleValue }
export type AiAgentTool = AgentTool & {
value: { tool_type: 'flowmodule' } & { type: 'aiagent' } & FlowModuleValue
@@ -0,0 +1,590 @@
<script lang="ts">
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import Path from '$lib/components/Path.svelte'
import LightweightResourcePicker from '$lib/components/LightweightResourcePicker.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { ResourceService, type InputTransform } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Bot, Save, Unlink, Pencil } from 'lucide-svelte'
import {
AGENT_BRAIN_KEYS,
AGENT_FLOW_LOCAL_KEYS,
agentConfigToInputTransforms,
flowLocalInputs,
inputTransformsToAgentConfig,
nonStaticBrainKeys,
summarizeAgentBrain,
type AIAgentConfig,
type AgentTool
} from '../agentResourceUtils'
import {
setLinkedAgentTools,
clearLinkedAgentTools,
linkedToolsScope
} from '../linkedAgentToolsStore.svelte'
import { getAgentEditingPath, setAgentEditingPath } from '../agentEditStore.svelte'
import { claimLinkedToolsFetch } from '../flowState'
import type { AgentTool as AgentToolStrict } from '../agentToolUtils'
import { resource } from 'runed'
import { untrack } from 'svelte'
let {
agent = $bindable(),
inputTransforms = $bindable(),
tools = $bindable(),
toolInputs = $bindable(),
moduleId,
opWorkspace = undefined,
flowPath = ''
}: {
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 $workspaceStore.
opWorkspace?: string
// Scope for the linked-agent tools store (the flow path); must match what the graph reads.
flowPath?: string
} = $props()
let ws = $derived(opWorkspace ?? $workspaceStore)
let saveDrawer: Drawer | undefined = $state()
let newPath = $state('')
let pathError = $state('')
let description = $state('')
let saving = $state(false)
let pickerValue: string | undefined = $state(undefined)
// The path "Save changes" upserts back to, for a step forked from a saved agent. Lives in an
// external store so it survives this component unmounting when another node is selected, keyed
// by the forked `tools` identity so a stale entry can't resurface (see agentEditStore).
let editingPath = $derived(getAgentEditingPath(tools))
type LinkedInfo = {
// What this result was fetched for. runed's resource neither aborts nor tags a superseded
// request, so a slow fetch for a previous link can land after a newer one: every consumer
// gates on these matching the current (ws, agent).
ws?: string
path?: string
config: AIAgentConfig
tools: AgentTool[]
providerPath?: string
providerOk: boolean
}
// 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).
let linkedResource = resource(
() => ({ ws, path: agent }),
async ({ ws, path }): Promise<LinkedInfo> => {
if (!ws || !path) {
return { ws, path, config: {}, tools: [], providerOk: true }
}
const res = await ResourceService.getResource({ workspace: ws, path })
const cfg = (res.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, config: cfg, tools, providerPath, providerOk }
}
)
// 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) {
loadedInfo = current
}
})
let linkedInfo = $derived(
loadedInfo?.ws === ws && loadedInfo?.path === agent ? loadedInfo : undefined
)
let inheritedTools = $derived(linkedInfo?.tools ?? [])
let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config))
let providerPath = $derived(linkedInfo?.providerPath)
let providerOk = $derived(linkedInfo?.providerOk ?? true)
// 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) {
claimLinkedToolsFetch(toolScope, moduleId)
// linkedResource types tools loosely; they are the same resource tools the store holds.
setLinkedAgentTools(toolScope, moduleId, loaded.tools as AgentToolStrict[])
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
}
})
// Link the step as soon as a saved agent is picked. Linking is rigid, so the step keeps no tools
// of its own — they come from the resource. The picked value is consumed immediately: a stale
// pickerValue must not re-link over an external change to `agent` (undo, session drafts).
$effect(() => {
if (pickerValue && pickerValue !== agent) {
agent = pickerValue
pickerValue = undefined
tools = []
toolInputs = {}
// Drop the step's brain transforms; a linked step keeps only the flow-local inputs.
inputTransforms = flowLocalInputs(inputTransforms)
}
})
function toolLabel(tool: AgentTool): string {
return tool.summary || tool.value?.tool_type || tool.id
}
function openSave() {
newPath = editingPath ?? ''
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).
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) {
throw new Error(providerSaveError)
}
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)
// 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 edit session ends or changes while the requests below are in flight (Cancel, undo,
// session-draft sync, a different agent opened for editing), the resource is still written but
// the step must not be relinked/cleared. Pinning the path — not merely "some edit is active" —
// is what distinguishes this session from a replacement one.
const forkMarker = tools
const savingEditPath = getAgentEditingPath(forkMarker)
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'
}
})
}
// Editing: a content-preserving refresh may have re-anchored the marker onto a clone of
// `tools`, which is still this session; a cleared or different path is not. Saving a
// standalone step has no marker to track, so only the fork's own array identifies it.
const sameSession =
savingEditPath === undefined
? tools === forkMarker
: getAgentEditingPath(tools) === savingEditPath
if (!sameSession) {
// 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 — 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
// Clear the edit entry while `tools` is still the fork's marker, before it's reassigned.
setAgentEditingPath(tools, undefined)
setAgentEditingPath(forkMarker, undefined)
// 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 updating = newPath === editingPath
const linked = await persist(newPath, description)
saveDrawer?.closeDrawer()
if (linked) {
sendUserToast(updating ? `Updated agent ${newPath}` : `Saved reusable agent ${newPath}`)
}
} catch (e) {
sendUserToast(`Failed to save agent: ${e}`, true)
} finally {
saving = false
}
}
// Save the forked-for-edit step back to the agent it came from, updating it in place.
async function saveChanges() {
if (!ws || !editingPath) {
return
}
saving = true
const path = editingPath
try {
if (await persist(path)) {
sendUserToast(`Updated agent ${path}`)
}
} catch (e) {
sendUserToast(`Failed to update agent: ${e}`, true)
} finally {
saving = false
}
}
// Copy the resource's brain + tools into the step, for Unlink (diverge here) and Edit (change the
// saved agent). Unlink folds this flow's tool_inputs into the tools and clears them, so the
// standalone step keeps its bindings; Edit must not fold, or those overrides would be promoted
// into the shared agent instead of surviving the re-link.
async function forkFromResource(foldOverrides: boolean): Promise<string | undefined> {
if (!ws || !agent) {
return undefined
}
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
const res = await ResourceService.getResource({ workspace: ws, path })
// The module may have been replaced while the fetch was in flight (undo, session drafts);
// applying a stale fork would overwrite the restored state and recreate the Editing target.
if (agent !== path || tools !== stepMarker) {
return undefined
}
const cfg = (res.value ?? {}) as AIAgentConfig
const brain = agentConfigToInputTransforms(cfg)
// 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]
}
}
inputTransforms = { ...brain, ...local }
const forkedTools = cfg.tools ?? []
if (foldOverrides) {
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
pickerValue = undefined
return path
}
// Unlink forks the agent into this step so it can diverge here. It does not write back.
async function unlink() {
try {
const path = await forkFromResource(true)
if (path) {
setAgentEditingPath(tools, undefined)
sendUserToast('Forked agent — its configuration was copied into this step')
} else {
sendUserToast('The step changed while loading the agent — nothing was unlinked', true)
}
} catch (e) {
sendUserToast(`Failed to unlink agent: ${e}`, true)
}
}
// Edit the saved agent itself: fork it into the step for editing, remembering the path so
// "Save changes" writes back to it (updating every flow that links to it).
async function editAgent() {
try {
const path = await forkFromResource(false)
if (path) {
setAgentEditingPath(tools, path)
sendUserToast(`Editing ${path} — make changes, then Save changes to update it`)
} else {
sendUserToast('The step changed while loading the agent — try Edit again', true)
}
} catch (e) {
sendUserToast(`Failed to edit agent: ${e}`, true)
}
}
// Cancel keeps the edits as a standalone step. Edit preserved the flow-local overrides for the
// re-link; fold them into the tools (as Unlink does) so the step owns its bindings outright,
// leaving nothing for the runtime's unlinked overlay to apply.
function cancelEdit() {
for (const tool of tools) {
const overrides = toolInputs?.[tool.id]
if (overrides && tool.value?.input_transforms) {
tool.value.input_transforms = { ...tool.value.input_transforms, ...overrides }
}
}
toolInputs = {}
setAgentEditingPath(tools, undefined)
}
</script>
<div class="px-2 xl:px-4 pt-2">
{#if agent}
<div class="rounded-md border border-light bg-surface-secondary px-3 py-2">
<div class="flex items-center gap-2 text-xs">
<Bot size={16} class="text-primary shrink-0" />
<span class="text-secondary shrink-0">Linked to</span>
<span class="flex min-w-0 flex-1 items-center gap-1">
<a
class="font-medium truncate"
href={`/resources?path=${agent}&workspace=${ws}`}
title={agent}>{agent}</a
>
<Tooltip small placement="bottom">
{#snippet text()}
Read-only: the configuration comes from this saved agent, and only the message and
inputs are set in this flow. Edit changes the agent everywhere it's used. Unlink forks
an editable copy into just this step.
{/snippet}
</Tooltip>
</span>
<div class="ml-auto flex items-center gap-1 shrink-0">
<Button
size="xs2"
variant="default"
startIcon={{ icon: Pencil }}
iconOnly
title="Edit the saved agent (updates it everywhere it's used)"
onclick={editAgent}
/>
<Button
size="xs2"
variant="default"
startIcon={{ icon: Unlink }}
iconOnly
title="Unlink (fork an editable copy into just this step)"
onclick={unlink}
/>
</div>
</div>
{#if brainParams.length > 0 || inheritedTools.length > 0}
<dl class="mt-2 flex flex-col gap-1 border-t border-light pt-2">
{#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)}
<span
class="inline-flex items-center rounded border border-light bg-surface px-1.5 py-0.5 text-secondary"
title={tool.id}
>
{toolLabel(tool)}
</span>
{/each}
</dd>
</div>
{/if}
</dl>
{/if}
</div>
{#if !providerOk}
<div class="mt-1">
<Alert type="error" size="xs" title="Model provider not accessible">
This agent's model provider{#if providerPath}
(<span class="font-medium">{providerPath}</span>){/if} isn't accessible in this workspace.
Unlink to fork the agent, or gain access to the provider resource.
</Alert>
</div>
{/if}
{:else if editingPath}
<div
class="flex items-center gap-2 rounded-md border border-light bg-surface-secondary px-3 py-2 text-xs"
>
<Pencil size={16} class="text-primary shrink-0" />
<span class="text-secondary">Editing</span>
<span class="font-medium truncate" title={editingPath}>{editingPath}</span>
<div class="ml-auto flex items-center gap-1">
<Button
size="xs2"
variant="accent"
startIcon={{ icon: Save }}
disabled={saving || !!providerSaveError}
onclick={saveChanges}
>
Save changes
</Button>
<Button size="xs2" variant="default" onclick={cancelEdit}>Cancel</Button>
</div>
</div>
<p class="text-2xs text-tertiary mt-1">
Editing the saved agent. Save changes updates it and re-links this step — the update
propagates to every flow that links to it. Cancel keeps your edits here as a standalone step
instead.
</p>
{#if providerSaveError}
<p class="text-2xs text-red-600 dark:text-red-400 mt-1">
{providerSaveError}
</p>
{/if}
{:else}
<div class="flex items-center gap-2">
<div class="grow min-w-0">
<LightweightResourcePicker
bind:value={pickerValue}
resourceType="ai_agent"
workspace={ws}
/>
</div>
<span class="text-2xs text-tertiary">or</span>
<Button size="xs2" variant="default" startIcon={{ icon: Save }} onclick={openSave}>
Save as agent
</Button>
</div>
{/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>
<Path
bind:path={newPath}
bind:error={pathError}
initialPath=""
namePlaceholder="my_agent"
kind="resource"
workspaceOverride={ws}
/>
<label class="flex flex-col gap-1 text-xs">
<span class="text-secondary">Description</span>
<TextInput
bind:value={description}
inputProps={{ placeholder: 'What this agent does' }}
size="sm"
/>
</label>
{#if providerSaveError}
<p class="text-xs text-red-600 dark:text-red-400">
{providerSaveError}
</p>
{/if}
</div>
{#snippet actions()}
<Button
variant="accent"
startIcon={{ icon: Save }}
disabled={!newPath || !!pathError || saving || !!providerSaveError}
onclick={saveAsAgent}
>
Save agent
</Button>
{/snippet}
</DrawerContent>
</Drawer>
@@ -0,0 +1,178 @@
<script lang="ts">
import { untrack } from 'svelte'
import { deepEqual } from 'fast-equals'
import type { InputTransform, ScriptLang } from '$lib/gen'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import { Wrench } from 'lucide-svelte'
import type { PickableProperties } from '../previousResults'
import {
agentToolToFlowModule,
isFlowModuleTool,
type AgentTool,
type FlowModuleTool
} from '../agentToolUtils'
import { flowLocalAgentSchema, toolInputOverrides } from '../agentResourceUtils'
import { loadSchemaFromModule } from '../flowInfers'
let {
tools,
toolInputs = $bindable(),
pickableProperties = undefined,
extraLib = 'missing extraLib',
workspace = undefined
}: {
// Tools inherited from the linked agent resource (read-only structure).
tools: AgentTool[]
// Host-local wiring authored on the step, keyed by tool id then input key. Only the diff from
// the resource tool's own transforms is stored (see toolInputOverrides).
toolInputs: Record<string, Record<string, InputTransform>>
pickableProperties?: PickableProperties
extraLib?: string
workspace?: string
} = $props()
let flowTools = $derived(tools.filter(isFlowModuleTool))
function baseInputs(tool: FlowModuleTool): Record<string, InputTransform> {
return ((tool.value as { input_transforms?: Record<string, InputTransform> })
.input_transforms ?? {}) as Record<string, InputTransform>
}
// Tool input schemas, inferred from each tool's definition (rawscript content / script path).
let schemas = $state<Record<string, any>>({})
// Editable copies (resource base overrides), one per tool. The form mutates these proxies in
// place, so a plain function-bind getter returning a fresh merged object would lose edits — the
// mirror effect below diffs them back into the step's tool_inputs instead.
let localArgs = $state<Record<string, Record<string, InputTransform>>>({})
// Non-reactive bookkeeping distinguishing our own mirror-writes from external tool_inputs
// changes (undo/redo, session drafts), and detecting resource-tool changes under a stable id.
let lastPublished: Record<string, Record<string, InputTransform>> = {}
let defSnaps: Record<string, string> = {}
// Seed and resynchronize the editable copies. Tracks the step's tool_inputs and the resource
// tools so external changes re-seed the form; our own mirror-writes match lastPublished and are
// left alone (no re-seed while typing).
$effect(() => {
const ts = flowTools
const incomingAll = $state.snapshot(toolInputs ?? {}) as Record<
string,
Record<string, InputTransform>
>
const ws = workspace
untrack(() => {
for (const t of ts) {
const base = baseInputs(t)
// Fingerprint the whole tool definition + workspace, not just the transforms: the
// inferred schema also depends on script content/path/language and the workspace, so a
// definition change under a stable id must re-infer and re-seed.
const defSnap = JSON.stringify([t.value, ws])
const incoming = incomingAll[t.id] ?? {}
const defChanged = defSnaps[t.id] !== defSnap
const external =
lastPublished[t.id] === undefined || !deepEqual(incoming, lastPublished[t.id])
if (localArgs[t.id] === undefined || defChanged || external) {
localArgs[t.id] = { ...base, ...incoming }
lastPublished[t.id] = incoming
const reloadSchema = defChanged || schemas[t.id] === undefined
defSnaps[t.id] = defSnap
if (reloadSchema) {
// Publish only if this request is still for the current definition — a slower
// load for a superseded definition must not overwrite the newest schema.
loadSchemaFromModule(agentToolToFlowModule(t), ws)
.then(({ schema }) => {
if (defSnaps[t.id] === defSnap) {
// A nested agent that is itself linked takes its brain from its own resource;
// the runtime overlays only the flow-local inputs, so offering the rest here
// would collect overrides it silently drops.
const v = t.value as { type?: string; agent?: string } | undefined
schemas[t.id] =
v?.type === 'aiagent' && v.agent ? flowLocalAgentSchema(schema) : schema
}
})
.catch(() => {
if (defSnaps[t.id] === defSnap) {
schemas[t.id] = { properties: {} }
}
})
}
}
}
})
})
// Mirror edits into the step, storing only the diff from the resource tool. Seeding is a no-op
// (the seed's diff equals the saved overrides), so merely opening the step doesn't dirty the flow.
$effect(() => {
const snap = $state.snapshot(localArgs) as Record<string, Record<string, InputTransform>>
untrack(() => {
for (const t of flowTools) {
const cur = snap[t.id]
if (!cur) continue
const overrides = toolInputOverrides(cur, baseInputs(t))
if (!deepEqual(toolInputs?.[t.id] ?? {}, overrides)) {
lastPublished[t.id] = overrides
if (Object.keys(overrides).length === 0) {
// Fully reverted: drop the key rather than keep an empty {} that dirties the flow.
const rest = { ...toolInputs }
delete rest[t.id]
toolInputs = rest
} else {
toolInputs = { ...toolInputs, [t.id]: overrides }
}
}
}
})
})
function toolCode(tool: FlowModuleTool): { language?: ScriptLang; content?: string } | undefined {
const v = tool.value as { type?: string; language?: ScriptLang; content?: string }
return v.type === 'rawscript' && v.content ? v : undefined
}
</script>
{#if flowTools.length > 0}
<div class="flex flex-col gap-2 px-2 pb-8 xl:px-4">
{#each flowTools as tool (tool.id)}
{@const code = toolCode(tool)}
<div class="rounded-md border border-light">
<div class="flex items-center gap-1.5 border-b border-light px-3 py-1.5 text-xs">
<Wrench size={14} class="shrink-0 text-tertiary" />
<span class="font-medium">{tool.summary || tool.id}</span>
{#if tool.description}
<span class="truncate text-2xs text-tertiary" title={tool.description}
>{tool.description}</span
>
{/if}
</div>
{#if schemas[tool.id] !== undefined && localArgs[tool.id] !== undefined}
<InputTransformSchemaForm
class="px-3 pt-1"
schema={schemas[tool.id]}
{pickableProperties}
{extraLib}
isAgentTool
bind:args={
() => localArgs[tool.id],
(v) => {
localArgs[tool.id] = v
}
}
/>
{:else}
<div class="px-3 py-2 text-2xs text-tertiary">Loading inputs...</div>
{/if}
{#if code}
<details class="border-t border-light">
<summary class="cursor-pointer px-3 py-1.5 text-2xs font-medium text-tertiary">
Tool code (read-only)
</summary>
<div class="max-h-64 overflow-auto border-t border-light p-2 text-xs">
<HighlightCode language={code.language} code={code.content ?? ''} />
</div>
</details>
{/if}
</div>
{/each}
</div>
{/if}
@@ -43,7 +43,7 @@
import SideBarTab from '$lib/components/meltComponents/SideBarTab.svelte'
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import { isObjectTooBig, readFieldsRecursively } from '$lib/utils'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
import type { AiAgent, ScriptLang } from '$lib/gen'
import { deepEqual } from 'fast-equals'
import Toggle from '$lib/components/Toggle.svelte'
@@ -867,7 +867,7 @@
onAddNew={(argName) => {
handleEditSchema('inputEditor')
editableSchemaForm?.openField(argName)
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
}}
>
{#snippet trigger()}
@@ -36,6 +36,10 @@
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowPathViewer from './FlowPathViewer.svelte'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
import AgentResourceBar from './AgentResourceBar.svelte'
import AgentToolBindings from './AgentToolBindings.svelte'
import { getLinkedAgentTools, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
import { flowLocalAgentSchema } from '../agentResourceUtils'
import FlowModuleMockTransitionMessage from './FlowModuleMockTransitionMessage.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { SecondsInput } from '$lib/components/common'
@@ -62,7 +66,7 @@
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
import AssetsDropdownButton from '$lib/components/assets/AssetsDropdownButton.svelte'
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
@@ -104,6 +108,21 @@
} = getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
// The agent editor's save guard uses the `tools` array identity to tell "same step" from "step
// replaced mid-save", so a module carrying no `tools` must read as one stable array — but a
// distinct one per module value, or a wholesale edit that keeps the module id would look
// unchanged. Keyed by the value object, which a replacement always renews.
type AgentTools = NonNullable<Extract<FlowModule['value'], { type: 'aiagent' }>['tools']>
const noToolsByValue = new WeakMap<object, AgentTools>()
function noTools(value: object): AgentTools {
let empty = noToolsByValue.get(value)
if (!empty) {
empty = []
noToolsByValue.set(value, empty)
}
return empty
}
let opWs = $derived(opWorkspace?.() ?? $workspaceStore)
interface Props {
@@ -142,6 +161,14 @@
siblingToolNames = undefined
}: Props = $props()
// Key for the linked-agent tools store. Ancestry-qualified for a nested agent tool, whose id
// comes from a resource and is not flow-global — it could otherwise alias a top-level step and
// read, then overwrite, that step's tools. Flow modules keep their bare id, which is what the
// graph looks them up by.
let linkedToolsModuleId = $derived(
parentModule?.value?.type === 'aiagent' ? `${parentModule.id}/${flowModule.id}` : flowModule.id
)
let workspaceScriptTag: string | undefined = $state(undefined)
let workspaceScriptLang: ScriptLang | undefined = $state(undefined)
let diffMode = $state(false)
@@ -173,6 +200,7 @@
flowModule.value.type === 'aiagent'
)
let visibleSelected = $derived(selected === 'chat' && !canShowChatTab ? 'inputs' : selected)
let agentLinked = $derived(flowModule.value.type === 'aiagent' && Boolean(flowModule.value.agent))
let advancedSelected = $state('retries')
let advancedRuntimeSelected = $state('concurrency')
let s3Kind = $state('s3_client')
@@ -1119,11 +1147,65 @@
class="absolute left-2 top-2 rounded-full w-2 h-2 bg-red-300"
></div>
{/if}
{#if flowModule.value.type === 'aiagent'}
<!-- Inside the wrapper so the card scrolls with the inputs (a single
scroll region) instead of stacking a second scrollbar above it. -->
<AgentResourceBar
moduleId={linkedToolsModuleId}
opWorkspace={opWs}
flowPath={$pathStore}
bind:agent={
() =>
flowModule.value.type === 'aiagent'
? flowModule.value.agent
: undefined,
(v) => {
if (flowModule.value.type === 'aiagent') {
flowModule.value.agent = v
}
}
}
bind:inputTransforms={
() => (flowModule.value as any).input_transforms,
(v) => {
if (flowModule.value.type === 'aiagent') {
;(flowModule.value as any).input_transforms = v
}
}
}
bind:tools={
() =>
flowModule.value.type === 'aiagent'
? (flowModule.value.tools ?? noTools(flowModule.value))
: noTools(flowModule),
(v) => {
if (flowModule.value.type === 'aiagent') {
flowModule.value.tools = v
}
}
}
bind:toolInputs={
() =>
flowModule.value.type === 'aiagent'
? (flowModule.value.tool_inputs ?? {})
: {},
(v) => {
if (flowModule.value.type === 'aiagent') {
// An emptied map reverts to absent so the doc matches its pre-override state.
flowModule.value.tool_inputs =
Object.keys(v).length > 0 ? v : undefined
}
}
}
/>
{/if}
<InputTransformSchemaForm
class="px-2 xl:px-4 pb-8"
bind:this={inputTransformSchemaForm}
pickableProperties={stepPropPicker.pickableProperties}
schema={flowStateStore.val[selectedId]?.schema ?? {}}
schema={agentLinked
? flowLocalAgentSchema(flowStateStore.val[selectedId]?.schema ?? {})
: (flowStateStore.val[selectedId]?.schema ?? {})}
previousModuleId={previousModule?.id}
bind:args={
() => {
@@ -1149,6 +1231,32 @@
helperScript={retrieveDynCodeAndLang(flowModule.value)}
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
/>
{#if agentLinked}
<!-- Linked agent: the resource's tools with their inputs rebindable to this
flow; overrides persist on the step as tool_inputs (diff from the resource). -->
<AgentToolBindings
tools={getLinkedAgentTools(
linkedToolsScope(opWs, $pathStore),
linkedToolsModuleId
)}
pickableProperties={stepPropPicker.pickableProperties}
extraLib={stepPropPicker.extraLib}
workspace={opWs}
bind:toolInputs={
() =>
flowModule.value.type === 'aiagent'
? (flowModule.value.tool_inputs ?? {})
: {},
(v) => {
if (flowModule.value.type === 'aiagent') {
// An emptied map reverts to absent so the doc matches its pre-override state.
flowModule.value.tool_inputs =
Object.keys(v).length > 0 ? v : undefined
}
}
}
/>
{/if}
</PropPickerWrapper>
</div>
{:else if visibleSelected === 'test'}
@@ -1176,7 +1284,9 @@
bind:this={modulePreview}
mod={flowModule}
{noEditor}
schema={flowStateStore.val[selectedId]?.schema ?? {}}
schema={agentLinked
? flowLocalAgentSchema(flowStateStore.val[selectedId]?.schema ?? {})
: (flowStateStore.val[selectedId]?.schema ?? {})}
bind:testJob
bind:testIsLoading
bind:scriptProgress
@@ -1586,11 +1696,14 @@
onUpdateMock={(detail) => {
flowModule.mock = detail
flowModule = flowModule
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
}}
{testJob}
{scriptProgress}
mod={flowModule}
linkedAgentTools={agentLinked
? getLinkedAgentTools(linkedToolsScope(opWs, $pathStore), linkedToolsModuleId)
: undefined}
{testIsLoading}
disableMock={preprocessorModule || failureModule}
disableHistory={failureModule}
@@ -22,7 +22,6 @@
import type { TriggerContext } from '$lib/components/triggers'
import { formatCron } from '$lib/utils'
import AgentToolWrapper from './AgentToolWrapper.svelte'
const { selectionManager, flowStateStore, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
@@ -303,17 +302,17 @@
{/if}
{/each}
{:else if flowModule.value.type === 'aiagent'}
{#each flowModule.value.tools as tool, toolIndex (toolIndex)}
{#each flowModule.value.tools ?? [] as tool, toolIndex (toolIndex)}
{#if selectedId === tool.id}
<AgentToolWrapper
{noEditor}
bind:tool={flowModule.value.tools[toolIndex]}
bind:tool={flowModule.value.tools![toolIndex]}
parentModule={flowModule}
{previousModule}
{enableAi}
{forceTestTab}
{highlightArg}
siblingToolNames={flowModule.value.tools.map((t) => t.summary ?? '')}
siblingToolNames={flowModule.value.tools!.map((t) => t.summary ?? '')}
/>
{/if}
{/each}
@@ -47,7 +47,7 @@
<div class="flex flex-col flex-1 h-full overflow-auto">
{#if flow}
<FlowGraphViewer triggerNode={true} {noSide} {flow} {fillAvailableHeight} />
<FlowGraphViewer triggerNode={true} {noSide} {flow} {fillAvailableHeight} workspace={opWs} />
{:else}
<Skeleton layout={[[40]]} />
{/if}
+12 -10
View File
@@ -2,11 +2,7 @@ import type { FlowModule } from '$lib/gen'
type FlowDfsOptions = { skipToolNodes?: boolean }
type FlowModuleVisitor<T> = (
x: FlowModule,
modules: FlowModule[],
branches: FlowModule[][]
) => T
type FlowModuleVisitor<T> = (x: FlowModule, modules: FlowModule[], branches: FlowModule[][]) => T
function traverseFlowModules(
modules: FlowModule[],
@@ -31,8 +27,10 @@ function traverseFlowModules(
traverseFlowModules(branch, visit, opts)
}
} else if (module.value.type == 'aiagent' && !opts.skipToolNodes) {
visit(module, modules, [module.value.tools as FlowModule[]])
traverseFlowModules(module.value.tools as FlowModule[], visit, opts)
// Absent on a linked agent, whose tools live in the resource.
const tools = (module.value.tools ?? []) as FlowModule[]
visit(module, modules, [tools])
traverseFlowModules(tools, visit, opts)
} else {
visit(module, modules, [])
}
@@ -45,9 +43,13 @@ export function dfs<T>(
opts: FlowDfsOptions = {}
): T[] {
let result: T[] = []
traverseFlowModules(modules, (module, parentModules, branches) => {
result.push(f(module, parentModules, branches))
}, opts)
traverseFlowModules(
modules,
(module, parentModules, branches) => {
result.push(f(module, parentModules, branches))
},
opts
)
return result
}
@@ -4,15 +4,11 @@ import type { SelectionManager } from '$lib/components/graph/selectionUtils.svel
import type { FlowStructureNode } from '$lib/components/graph/flowStructure'
import type { OpenFlow } from '$lib/gen'
import { push, type History } from '$lib/history.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { StateStore } from '$lib/utils'
import {
createDeletePlan,
removeDeletePlanTools,
type DeletePlan
} from './flowDeleteUtils'
import { createDeletePlan, removeDeletePlanTools, type DeletePlan } from './flowDeleteUtils'
import type { FlowState } from './flowState'
import { deleteFlowStateById } from './flowStateUtils.svelte'
import { refreshFlowStateStore } from './flowStoreRefresh.svelte'
export type PreparedDeleteRequest = {
plan: DeletePlan
@@ -76,7 +72,7 @@ export function executeDeletePlan(
deleteFlowStateById(id, args.flowStateStore)
}
refreshStateStore(args.flowStore)
refreshFlowStateStore(args.flowStore)
if (plan.inputIds.length === 1) {
args.onDelete?.(plan.targets[0].id)
@@ -3,6 +3,7 @@ import { loadSchemaFlow } from '$lib/scripts'
import type { Schema } from '$lib/common'
import { emptySchema } from '$lib/utils'
import type { FlowModule, InputTransform } from '$lib/gen'
import { AGENT_FLOW_LOCAL_KEYS } from './agentResourceUtils'
export const AI_AGENT_SCHEMA: Schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
@@ -276,8 +277,14 @@ export async function loadSchemaFromModule(
}
} else if (mod.type === 'aiagent') {
let input_transforms = migrateAiAgentInputTransforms(mod.input_transforms ?? {})
// A linked step's brain lives in the resource, so only the flow-local inputs get a placeholder
// transform: filling the brain keys back in would re-add the very fields linking strips, and
// they would be persisted on the next save.
const keys = mod.agent
? (AGENT_FLOW_LOCAL_KEYS as readonly string[])
: Object.keys(AI_AGENT_SCHEMA.properties ?? {})
return {
input_transforms: Object.keys(AI_AGENT_SCHEMA.properties ?? {}).reduce((accu, key) => {
input_transforms: keys.reduce((accu, key) => {
accu[key] = input_transforms[key] ?? {
type: 'static',
value: undefined
+103 -16
View File
@@ -1,6 +1,10 @@
import type { Schema } from '$lib/common'
import type { Flow, FlowModule } from '$lib/gen'
import { isFlowModuleTool, agentToolToFlowModule } from './agentToolUtils'
import { ResourceService } from '$lib/gen'
import { get } from 'svelte/store'
import { workspaceStore } from '$lib/stores'
import { isFlowModuleTool, agentToolToFlowModule, type AgentTool } from './agentToolUtils'
import { linkedToolsScope, setLinkedAgentTools } from './linkedAgentToolsStore.svelte'
import { loadFlowModuleState } from './flowStateUtils.svelte'
import { emptyFlowModuleState } from './utils.svelte'
import type { StateStore } from '$lib/utils'
@@ -16,6 +20,9 @@ export type FlowModuleState = {
export type FlowState = Record<string, FlowModuleState>
// Latest linked-tool fetch per (scope, agent module); see the publish guard below.
const linkedToolFetchGen = new Map<string, number>()
/**
* flowStateStore represents the local state of each module indexed by its id.
* It contains data loaded that are not contained in a Flow object i.e. schemas.
@@ -26,11 +33,17 @@ export async function initFlowState(
flow: Flow,
flowStateStore: StateStore<FlowState>,
// The acting workspace when the flow editor runs in an AI session; else the nav workspace.
workspace?: string
workspace: string | undefined,
// Flow path half of the linked-agent tools scope; keeps agents that share a module id across
// simultaneously-shown flows from aliasing each other. Required so call sites can't silently
// publish into the '' bucket while the graph reads the real flow path.
flowPath: string
) {
const modulesState: FlowState = {}
await mapFlowModules(flow.value.modules, modulesState, workspace)
const ws = workspace ?? get(workspaceStore)
const scope = linkedToolsScope(ws, flowPath)
await mapFlowModules(flow.value.modules, modulesState, workspace, scope)
const failureModule = flow.value.failure_module
? await loadFlowModuleState(flow.value.failure_module, workspace)
@@ -46,31 +59,45 @@ export async function initFlowState(
* mapFlowModule recursively explore the flow, following deeply nested loop and branches modules
* to build the initial state.
*/
async function mapFlowModule(flowModule: FlowModule, modulesState: FlowState, workspace?: string) {
async function mapFlowModule(
flowModule: FlowModule,
modulesState: FlowState,
workspace?: string,
scope: string = ''
) {
const value = flowModule.value
if (value.type === 'forloopflow') {
await mapFlowModules(value.modules, modulesState, workspace)
if (value.type === 'forloopflow' || value.type === 'whileloopflow') {
await mapFlowModules(value.modules, modulesState, workspace, scope)
}
if (value.type === 'branchone') {
await mapFlowModules(value.default, modulesState, workspace)
await mapFlowModules(value.default, modulesState, workspace, scope)
}
if (value.type === 'branchone' || value.type === 'branchall') {
await Promise.all(
value.branches.map(
(branchModule: { summary?: string; skip_failure?: boolean; modules: Array<FlowModule> }) =>
mapFlowModules(branchModule.modules, modulesState, workspace)
mapFlowModules(branchModule.modules, modulesState, workspace, scope)
)
)
}
if (value.type === 'aiagent' && value.tools) {
await Promise.all(
value.tools.filter(isFlowModuleTool).map(async (tool) => {
modulesState[tool.id] = await loadFlowModuleState(agentToolToFlowModule(tool), workspace)
})
)
if (value.type === 'aiagent') {
const agentRef = (value as { agent?: string }).agent
if (agentRef) {
// A linked step's tools come from the resource (its own `tools` is empty); resolve them so
// the graph can render its tool nodes. They are display-only (their inputs are edited in
// the step panel, which infers schemas itself), so no per-tool module state is loaded —
// resource tool ids are not flow-unique and must not key into the flow state.
await publishLinkedAgentTools(agentRef, workspace, scope, flowModule.id)
} else {
await Promise.all(
(value.tools ?? []).filter(isFlowModuleTool).map(async (tool) => {
modulesState[tool.id] = await loadFlowModuleState(agentToolToFlowModule(tool), workspace)
})
)
}
}
if (value.type === 'identity') {
@@ -81,12 +108,72 @@ async function mapFlowModule(flowModule: FlowModule, modulesState: FlowState, wo
}
}
// Resolve a linked agent's tools and publish them into the store. Concurrent re-resolutions of the
// same (scope, module) race — un-awaited re-inits from session-draft sync, or a link swapped while
// the previous fetch is in flight — so only the latest may publish, else a superseded agent's tools
// overwrite the newer one.
export async function publishLinkedAgentTools(
agentRef: string,
workspace: string | undefined,
scope: string,
moduleId: string
) {
const genKey = `${scope}:${moduleId}`
const gen = claimLinkedToolsFetch(scope, moduleId)
const tools = await resolveLinkedAgentTools(agentRef, workspace)
if (linkedToolFetchGen.get(genKey) === gen) {
setLinkedAgentTools(scope, moduleId, tools)
}
}
/** Supersede every in-flight fetch for a scope. A rename moves the bucket to a new key, so a fetch
* still running against the old one would publish there and then be swept forward over tools that
* resolved later under the new key. */
export function invalidateLinkedToolsFetches(scope: string) {
const prefix = `${scope}:`
for (const key of linkedToolFetchGen.keys()) {
if (key.startsWith(prefix)) {
linkedToolFetchGen.set(key, (linkedToolFetchGen.get(key) ?? 0) + 1)
}
}
}
/** Supersede any in-flight fetch for this (scope, module) and return the new generation. Anything
* that publishes or clears tools outside `publishLinkedAgentTools` must claim first, or an older
* fetch still passes its own check and overwrites the newer result. */
export function claimLinkedToolsFetch(scope: string, moduleId: string): number {
const genKey = `${scope}:${moduleId}`
const gen = (linkedToolFetchGen.get(genKey) ?? 0) + 1
linkedToolFetchGen.set(genKey, gen)
return gen
}
// Fetch a linked agent's tool set from its `ai_agent` resource. Degrades to no tools when the
// resource is missing or inaccessible so a broken link never stalls the flow load.
export async function resolveLinkedAgentTools(
agentRef: string,
workspace?: string
): Promise<AgentTool[]> {
const ws = workspace ?? get(workspaceStore)
if (!ws) return []
const path = agentRef.replace(/^\$res:/, '').replace(/^res:\/\//, '')
try {
const res = await ResourceService.getResource({ workspace: ws, path })
return ((res.value as { tools?: AgentTool[] } | undefined)?.tools ?? []) as AgentTool[]
} catch {
return []
}
}
async function mapFlowModules(
flowModules: FlowModule[],
modulesState: FlowState,
workspace?: string
workspace?: string,
scope: string = ''
) {
await Promise.all(
flowModules.map((flowModule: FlowModule) => mapFlowModule(flowModule, modulesState, workspace))
flowModules.map((flowModule: FlowModule) =>
mapFlowModule(flowModule, modulesState, workspace, scope)
)
)
}
@@ -14,7 +14,7 @@ export async function initFlow(
// The acting workspace when the flow editor runs in an AI session; else the nav workspace.
workspace?: string
) {
await initFlowState(flow, flowStateStore, workspace)
await initFlowState(flow, flowStateStore, workspace, flow.path ?? '')
flowStore.val = flow
}
@@ -0,0 +1,15 @@
import type { OpenFlow } from '$lib/gen'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { StateStore } from '$lib/utils'
import { reanchorAgentEditsAcross } from './agentEditStore.svelte'
// Use at content-preserving refresh sites (structural edits, schema/failure/mock changes): a bare
// refreshStateStore clone would break the agent edit marker's array identity. Content-changing
// replacements (undo, YAML/diff/AI apply, session restores) must stay bare so stale edit state
// keeps invalidating.
export function refreshFlowStateStore(flowStore: StateStore<OpenFlow>) {
reanchorAgentEditsAcross(
() => flowStore.val.value?.modules,
() => refreshStateStore(flowStore)
)
}
@@ -0,0 +1,134 @@
import { deepEqual } from 'fast-equals'
import type { AgentTool } from './agentToolUtils'
// Tools resolved from a linked agent's `ai_agent` resource: the step stores `tools: []`, so the graph
// and the tool editor read the resolved set from here. Keyed by scope then module id — the scope
// keeps flows shown at the same time (an editor and an embedded preview) from aliasing each other.
let byScope = $state<Record<string, Record<string, AgentTool[]>>>({})
// A long-lived tab would otherwise keep every flow it ever visited (raw tool script contents
// included). Past this cap the least recently used scope is evicted, skipping any a mounted view
// still holds.
const MAX_SCOPES = 32
let scopeOrder: string[] = []
// Recency on read, so a scope still being displayed isn't evicted by unrelated publishes (a run
// viewer opens one bucket per nested job). Only reorders the plain recency list — evicting here
// would mutate reactive state during a render.
function noteScopeRead(scope: string) {
if (byScope[scope] === undefined) return
const last = scopeOrder[scopeOrder.length - 1]
if (last === scope) return
scopeOrder = [...scopeOrder.filter((s) => s !== scope), scope]
}
// Scopes a mounted view is currently relying on. A run viewer keeps one per nested job, hidden ones
// included, so the cap alone would evict buckets still in use — and restoring one would evict
// another, forever. Retained scopes are never evicted; the cap yields to correctness.
const retainedScopes = new Map<string, number>()
// A just-renamed scope, protected from eviction until a reader retains it: holders release the old
// key before retaining the new one, so the migrated bucket is unretained in between.
let pendingMigration: string | undefined = undefined
export function retainLinkedToolsScope(scope: string) {
retainedScopes.set(scope, (retainedScopes.get(scope) ?? 0) + 1)
if (scope === pendingMigration) {
pendingMigration = undefined
}
}
export function releaseLinkedToolsScope(scope: string) {
const next = (retainedScopes.get(scope) ?? 0) - 1
if (next > 0) {
retainedScopes.set(scope, next)
} else {
retainedScopes.delete(scope)
}
// Scopes skipped while retained are otherwise never reconsidered, leaving the store over the cap
// for the tab's life once enough views have been closed.
evictOverCap()
}
// `protect` is the scope just written: it is the newest, so when every older one is retained it
// would otherwise be the only eligible victim and get dropped the moment it was published.
function evictOverCap(protect?: string) {
while (scopeOrder.length > MAX_SCOPES) {
const victim = scopeOrder.find(
(s) => s !== protect && s !== pendingMigration && !retainedScopes.has(s)
)
if (victim === undefined) {
break
}
scopeOrder = scopeOrder.filter((s) => s !== victim)
delete byScope[victim]
}
}
function touchScope(scope: string) {
scopeOrder = [...scopeOrder.filter((s) => s !== scope), scope]
evictOverCap(scope)
}
/**
* Scope key for the store: workspace + flow path. Flow paths repeat across workspaces, so a late
* async resolution from a previous workspace must land in its own bucket instead of overwriting
* the tools of an identically-named flow in the current one. Every publisher and reader must
* derive the workspace the same way (operating workspace, falling back to the nav workspace).
*/
export function linkedToolsScope(
workspace: string | undefined,
flowPath: string | undefined
): string {
return `${workspace ?? ''}:${flowPath ?? ''}`
}
// Bumped on every mutation. Non-reactive graph recomputations (which read the map inside untrack)
// track this to re-run when a link resolves after the initial render, e.g. right after linking.
let version = $state(0)
export function setLinkedAgentTools(scope: string, moduleId: string, tools: AgentTool[]) {
// Publishers re-run and hand us a fresh-but-equal array each time; only mutate on a real change,
// else the version bump would retrigger the graph recompute in a loop.
if (deepEqual(byScope[scope]?.[moduleId], tools)) return
byScope[scope] = { ...(byScope[scope] ?? {}), [moduleId]: tools }
touchScope(scope)
version++
}
/** Move one scope's resolutions into another: used when a rename moves readers to a new scope,
* and to sweep republished data (keyed by the flow doc's path) into the live-edited scope. The
* source bucket carries the newer resolution in both cases, so it wins the merge. */
export function migrateLinkedAgentToolsScope(oldScope: string, newScope: string) {
if (oldScope === newScope || byScope[oldScope] === undefined) return
byScope[newScope] = { ...(byScope[newScope] ?? {}), ...(byScope[oldScope] ?? {}) }
delete byScope[oldScope]
pendingMigration = newScope
// Mark the new key most-recently-used but don't evict here: the next publish or release enforces
// the cap, by which point readers have re-retained under the new key.
scopeOrder = [...scopeOrder.filter((s) => s !== oldScope && s !== newScope), newScope]
version++
}
export function clearLinkedAgentTools(scope: string, moduleId: string) {
if (byScope[scope]?.[moduleId] === undefined) return
const rest = { ...byScope[scope] }
delete rest[moduleId]
byScope[scope] = rest
version++
}
export function getLinkedAgentTools(scope: string, moduleId: string): AgentTool[] {
noteScopeRead(scope)
return byScope[scope]?.[moduleId] ?? []
}
/** Reactive snapshot of one scope's moduletools map read this where a computation must react to
* resolution (the graph passes it to computeAIToolNodes, which indexes it by module id). */
export function linkedAgentToolsForScope(scope: string): Record<string, AgentTool[]> {
noteScopeRead(scope)
return byScope[scope] ?? {}
}
/** Reactive counter that changes on any link resolution. Track it to trigger a recompute. */
export function linkedAgentToolsVersion(): number {
return version
}
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest'
import {
clearLinkedAgentTools,
getLinkedAgentTools,
linkedToolsScope,
migrateLinkedAgentToolsScope,
releaseLinkedToolsScope,
retainLinkedToolsScope,
setLinkedAgentTools
} from './linkedAgentToolsStore.svelte'
import type { AgentTool } from './agentToolUtils'
// The cap in the store. Filling past it is the only way to observe eviction.
const MAX_SCOPES = 32
function tool(id: string): AgentTool {
return { id, value: { tool_type: 'flowmodule', type: 'script', path: `u/t/${id}` } } as AgentTool
}
// Unique per test so the module-level store doesn't leak between them.
let seq = 0
function freshScope(name = 'flow') {
return linkedToolsScope(`ws${seq++}`, name)
}
function fillPastCap(exclude: string) {
for (let i = 0; i <= MAX_SCOPES; i++) {
const filler = linkedToolsScope('filler', `${exclude}-${seq}-${i}`)
setLinkedAgentTools(filler, 'm', [tool(`f${i}`)])
}
}
describe('linkedAgentToolsStore', () => {
it('keeps each scope module map separate', () => {
const a = freshScope()
const b = freshScope()
setLinkedAgentTools(a, 'step', [tool('x')])
setLinkedAgentTools(b, 'step', [tool('y')])
expect(getLinkedAgentTools(a, 'step').map((t) => t.id)).toEqual(['x'])
expect(getLinkedAgentTools(b, 'step').map((t) => t.id)).toEqual(['y'])
})
it('evicts an unretained scope once past the cap', () => {
const victim = freshScope()
setLinkedAgentTools(victim, 'step', [tool('x')])
fillPastCap(victim)
expect(getLinkedAgentTools(victim, 'step')).toEqual([])
})
// A run viewer holds one scope per mounted nested job; those must not evict what is on screen.
it('never evicts a retained scope', () => {
const held = freshScope()
setLinkedAgentTools(held, 'step', [tool('x')])
retainLinkedToolsScope(held)
fillPastCap(held)
expect(getLinkedAgentTools(held, 'step').map((t) => t.id)).toEqual(['x'])
releaseLinkedToolsScope(held)
})
// Retained scopes can push the store past the cap, since eviction skips them. Releasing one has
// to prune, or closing views would leave it over the cap for the tab's life.
it('prunes on release once retained scopes have filled the cap', () => {
const held: string[] = []
for (let i = 0; i <= MAX_SCOPES; i++) {
const scope = linkedToolsScope('retained', `${seq}-${i}`)
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)])
retainLinkedToolsScope(scope)
held.push(scope)
}
// All retained, so nothing was evicted and the store sits over the cap.
expect(getLinkedAgentTools(held[0], 'step').map((t) => t.id)).toEqual(['t0'])
releaseLinkedToolsScope(held[0])
expect(getLinkedAgentTools(held[0], 'step')).toEqual([])
expect(getLinkedAgentTools(held[held.length - 1], 'step')).toHaveLength(1)
for (const scope of held.slice(1)) {
releaseLinkedToolsScope(scope)
}
})
// A rename moves readers to a new scope; they only retain the new key afterwards, so the
// migration itself must not let the fresh bucket be evicted.
it('carries tools across a rename without evicting the new scope', () => {
const from = freshScope('old')
const to = freshScope('new')
setLinkedAgentTools(from, 'step', [tool('x')])
retainLinkedToolsScope(from)
migrateLinkedAgentToolsScope(from, to)
expect(getLinkedAgentTools(to, 'step').map((t) => t.id)).toEqual(['x'])
expect(getLinkedAgentTools(from, 'step')).toEqual([])
releaseLinkedToolsScope(from)
})
// Readers release the old scope before retaining the new one. Over the cap, with everything else
// retained, the migrated bucket would be the only evictable entry in that window.
it('keeps migrated tools when the old scope is released before the new one is retained', () => {
const held: string[] = []
for (let i = 0; i < MAX_SCOPES; i++) {
const scope = linkedToolsScope('rename-fill', `${seq}-${i}`)
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)])
retainLinkedToolsScope(scope)
held.push(scope)
}
const from = freshScope('before')
const to = freshScope('after')
setLinkedAgentTools(from, 'step', [tool('x')])
retainLinkedToolsScope(from)
migrateLinkedAgentToolsScope(from, to)
releaseLinkedToolsScope(from)
retainLinkedToolsScope(to)
expect(getLinkedAgentTools(to, 'step').map((t) => t.id)).toEqual(['x'])
releaseLinkedToolsScope(to)
for (const scope of held) {
releaseLinkedToolsScope(scope)
}
})
it('clears one module without disturbing its siblings', () => {
const scope = freshScope()
setLinkedAgentTools(scope, 'a', [tool('x')])
setLinkedAgentTools(scope, 'b', [tool('y')])
clearLinkedAgentTools(scope, 'a')
expect(getLinkedAgentTools(scope, 'a')).toEqual([])
expect(getLinkedAgentTools(scope, 'b').map((t) => t.id)).toEqual(['y'])
})
})
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest'
// flowState pulls in the flow-module loaders; only the fetch-generation guard is under test here.
vi.mock('$lib/gen', () => ({ ResourceService: { getResource: vi.fn() } }))
vi.mock('./flowStateUtils.svelte', () => ({ loadFlowModuleState: vi.fn() }))
vi.mock('./utils.svelte', () => ({ emptyFlowModuleState: () => ({}) }))
vi.mock('./agentToolUtils', () => ({
isFlowModuleTool: () => false,
agentToolToFlowModule: (t: unknown) => t
}))
vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: (f: (v: string) => void) => (f('ws'), () => {}) } }))
import {
claimLinkedToolsFetch,
invalidateLinkedToolsFetches,
publishLinkedAgentTools
} from './flowState'
import {
getLinkedAgentTools,
linkedToolsScope,
setLinkedAgentTools
} from './linkedAgentToolsStore.svelte'
import { ResourceService } from '$lib/gen'
import type { AgentTool } from './agentToolUtils'
const tool = (id: string) => ({ id, value: { tool_type: 'flowmodule' } }) as AgentTool
let seq = 0
const scopeFor = (name: string) => linkedToolsScope(`ws${seq++}`, name)
describe('linked tools fetch guard', () => {
it('lets only the newest fetch for a (scope, module) publish', async () => {
const scope = scopeFor('flow')
let release: ((v: unknown) => void) | undefined
vi.mocked(ResourceService.getResource)
.mockImplementationOnce(
() => new Promise((r) => (release = r)) as ReturnType<typeof ResourceService.getResource>
)
.mockResolvedValueOnce({ value: { tools: [tool('new')] } } as never)
const stale = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
await publishLinkedAgentTools('f/a/new', 'ws', scope, 'step')
release?.({ value: { tools: [tool('old')] } })
await stale
expect(getLinkedAgentTools(scope, 'step').map((t) => t.id)).toEqual(['new'])
})
// A rename migrates the bucket to a new scope. A fetch still running against the old scope holds
// a valid generation for that key, so without invalidation it publishes there and the doc-scope
// sweep carries it forward over the link resolved since.
it('invalidates every in-flight fetch for a scope', async () => {
const scope = scopeFor('before-rename')
let release: ((v: unknown) => void) | undefined
vi.mocked(ResourceService.getResource).mockImplementationOnce(
() => new Promise((r) => (release = r)) as ReturnType<typeof ResourceService.getResource>
)
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
setLinkedAgentTools(scope, 'step', [tool('kept')])
invalidateLinkedToolsFetches(scope)
release?.({ value: { tools: [tool('stale')] } })
await inFlight
expect(getLinkedAgentTools(scope, 'step').map((t) => t.id)).toEqual(['kept'])
})
it('claiming supersedes an in-flight fetch for that module', async () => {
const scope = scopeFor('claimed')
let release: ((v: unknown) => void) | undefined
vi.mocked(ResourceService.getResource).mockImplementationOnce(
() => new Promise((r) => (release = r)) as ReturnType<typeof ResourceService.getResource>
)
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
claimLinkedToolsFetch(scope, 'step')
setLinkedAgentTools(scope, 'step', [tool('direct')])
release?.({ value: { tools: [tool('stale')] } })
await inFlight
expect(getLinkedAgentTools(scope, 'step').map((t) => t.id)).toEqual(['direct'])
})
})
@@ -7,7 +7,7 @@
import { insertNewFailureModule } from '$lib/components/flows/flowStateUtils.svelte'
import type { RawScript, ScriptLang } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import DiffActionBar from './DiffActionBar.svelte'
import { getNodeColorClasses, aiActionToNodeState } from '$lib/components/graph'
@@ -58,7 +58,7 @@
}
selectionManager.selectId('failure')
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
}
const smallFailureModule = $derived(!(failureModuleId && diffManager && moduleAction) && compact)
@@ -36,7 +36,7 @@
import { findModuleInFlow } from '../flowTree'
import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte'
import { MoveManager } from '$lib/components/graph/moveManager.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
import type { GraphModuleState } from '$lib/components/graph'
import FlowStickyNode from './FlowStickyNode.svelte'
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
@@ -76,7 +76,6 @@
disableSettings?: boolean
newFlow?: boolean
smallErrorHandler?: boolean
workspace?: string | undefined
onTestUpTo?: ((id: string) => void) | undefined
onEditInput?: (moduleId: string, key: string) => void
localModuleStates?: Record<string, GraphModuleState>
@@ -108,7 +107,6 @@
disableSettings = false,
newFlow = false,
smallErrorHandler = false,
workspace = $workspaceStore,
onTestUpTo,
onEditInput,
localModuleStates = {},
@@ -426,7 +424,7 @@
parentArr.splice(lastIndex + 1, 0, ...clones)
}
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
selectionManager.selectByIds(allCloneIds)
}
@@ -599,7 +597,7 @@
failureModule={flowStore.val.value?.failure_module}
currentInputSchema={flowStore.val.schema}
{selectionManager}
{workspace}
workspace={opWs}
editMode
{onTestUpTo}
{onEditInput}
@@ -686,7 +684,7 @@
selectionManager.selectId(movingId)
}
moveManager.clearMoving()
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
dispatch('change')
}
@@ -720,7 +718,7 @@
instructions: detail.inlineScript?.instructions
})
}
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
dispatch('change')
return
}
@@ -737,8 +735,12 @@
// Agent tool inserts operate on the FlowModule's tools array directly
if (isAgentInsert) {
const agentMod = findModuleInFlow(flowStore.val.value, detail.agentId!)
if (agentMod && (agentMod.value as any).tools) {
const tools = (agentMod.value as any).tools as AgentTool[]
const agentValue = agentMod?.value as { tools?: AgentTool[] } | undefined
if (agentValue) {
// `tools` is optional, so a module authored without it starts undefined here and
// would silently swallow its first tool.
agentValue.tools ??= []
const tools = agentValue.tools
await insertNewModuleAtIndex(
tools,
tools.length,
@@ -751,7 +753,7 @@
const id = tools[tools.length - 1].id
selectionManager.selectId(id)
}
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
dispatch('change')
return
}
@@ -829,13 +831,13 @@
if (['branchone', 'branchall'].includes(detail.kind)) {
await addBranch(module.id)
}
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
dispatch('change')
}}
onNewBranch={async (id) => {
if (id) {
await addBranch(id)
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
}
}}
onSelect={(id) => {
@@ -889,13 +891,13 @@
}
flowStateStore.val[newId] = flowStateStore.val[id]
delete flowStateStore.val[id]
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
selectionManager.selectId(newId)
}}
onDeleteBranch={async ({ id, index }) => {
if (id) {
await removeBranch(id, index)
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
selectionManager.selectId(id)
}
}}
@@ -934,7 +936,7 @@
})
targetModules.splice(targetIndex + 1, 0, clone)
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
selectionManager.selectId(clone.id)
}}
onUpdateMock={(detail) => {
@@ -943,7 +945,7 @@
throw new Error(`Node ${detail.id} not found`)
}
module.mock = $state.snapshot(detail.mock)
refreshStateStore(flowStore)
refreshFlowStateStore(flowStore)
}}
{onTestFlow}
{isRunning}
@@ -263,7 +263,7 @@
{...itemProps}
{nodeState}
label={mod.summary ||
(mod.value.type === 'aiagent' ? 'AI Agent' : undefined) ||
(mod.value.type === 'aiagent' ? (mod.value.agent ?? 'AI Agent') : undefined) ||
(mod.id === 'preprocessor'
? 'Preprocessor'
: mod.id.startsWith('failure')
@@ -48,9 +48,9 @@ export function evalValue(
let v: any
let t = inputTransforms?.[k]
if (t.type == 'static') {
if (t?.type == 'static') {
v = t.value
} else if (t.type == 'javascript') {
} else if (t?.type == 'javascript') {
try {
let context = {
flow_input: pickableProperties?.flow_input,
@@ -57,6 +57,13 @@
import AssetsOverflowedNode from './renderers/nodes/AssetsOverflowedNode.svelte'
import type { FlowGraphAssetContext } from '../flows/types'
import AiToolNode, { computeAIToolNodes } from './renderers/nodes/AIToolNode.svelte'
import {
linkedAgentToolsForScope,
linkedAgentToolsVersion,
linkedToolsScope,
releaseLinkedToolsScope,
retainLinkedToolsScope
} from '$lib/components/flows/linkedAgentToolsStore.svelte'
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
import NoteNode from './renderers/nodes/NoteNode.svelte'
import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte'
@@ -127,6 +134,9 @@
moduleActions?: Record<string, ModuleActionInfo>
selectionManager?: SelectionManager
path?: string | undefined
// Flow path for the linked-agent tools bucket. Separate from `path` because that one also
// drives the Trigger node, which read-only viewers must not render.
linkedToolsPath?: string | undefined
newFlow?: boolean
insertable?: boolean
earlyStop?: boolean
@@ -230,6 +240,7 @@
moduleActions = undefined,
selectionManager: selectionManagerProp = undefined,
path = undefined,
linkedToolsPath = undefined,
newFlow = false,
insertable = false,
earlyStop = false,
@@ -280,6 +291,14 @@
onHeight = undefined
}: Props = $props()
// Hold the scope this graph draws from while it is mounted: the store's cap must never drop a
// bucket that something on screen is reading, and nothing would refetch it afterwards.
$effect(() => {
const scope = linkedToolsScope(workspace, linkedToolsPath ?? path)
retainLinkedToolsScope(scope)
return () => releaseLinkedToolsScope(scope)
})
// Initialize note manager with fine-grained reactivity
const noteManager = new NoteManager(
() => notes ?? [],
@@ -664,6 +683,9 @@
currentGraphNodeDeps = graphNodeDeps
// Pre-compute extra space per node for assets, AI tools, group notes, group headers
const resolvedLinkedTools = linkedAgentToolsForScope(
linkedToolsScope(workspace, linkedToolsPath ?? path)
)
const nodeExtraSpace = computeNodeExtraSpace(graphNodeDeps, {
showAssets: $showAssets ?? true,
showNotes,
@@ -671,7 +693,8 @@
noteTextHeights,
groupDisplayState,
insertable,
flowModuleStates
flowModuleStates,
linkedAgentTools: resolvedLinkedTools
})
// Layout with extra space baked into sugiyama
@@ -698,7 +721,13 @@
: undefined
// Compute AI tool visual nodes (no position remapping)
let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates)
let aiToolNodesResult = computeAIToolNodes(
newNodes,
eventHandler,
insertable,
flowModuleStates,
resolvedLinkedTools
)
let finalNodes: (Node & NodeLayout)[] = [
...newNodes,
@@ -933,7 +962,9 @@
showNotes,
noteManager.renderCount,
currentGroups,
groupDisplayState.renderCount
groupDisplayState.renderCount,
// A linked step's tools resolve asynchronously; recompute tool nodes when they land.
linkedAgentToolsVersion()
]
untrack(async () => {
await updateStores()
@@ -315,7 +315,13 @@ export type AiToolN = {
nameError?: string
eventHandlers: GraphEventHandlers
moduleId: string
// Set on a linked agent's display-only tools: clicking selects this module (the agent step)
// instead of the tool, whose resource-owned id is not flow-unique.
selectTarget?: string
insertable: boolean
// Tool of a linked agent: its inputs are editable but its structure comes from the resource,
// so it can't be deleted here.
readOnly?: boolean
flowModuleStates: Record<string, GraphModuleState> | undefined
}
}
@@ -40,6 +40,9 @@ export function computeNodeExtraSpace(
groupDisplayState: GroupDisplayState
insertable: boolean
flowModuleStates: Record<string, GraphModuleState> | undefined
// A linked agent's tools live in the store, not on the module, so its own `tools` is empty:
// without these the layout reserves no room and the tool nodes overlap the node above.
linkedAgentTools: Record<string, unknown[]> | undefined
}
): Map<string, ExtraSpace> | undefined {
const extraSpace = new Map<string, ExtraSpace>()
@@ -77,8 +80,12 @@ export function computeNodeExtraSpace(
extraSpace.set(node.id, { ...prev, bottom: prev.bottom + space })
} else {
// Edit mode: tools above
const tools = mod.value.tools ?? []
const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (opts.insertable ? 1 : 0)
const tools = mod.value.agent
? (opts.linkedAgentTools?.[node.id] ?? [])
: (mod.value.tools ?? [])
// A linked agent has no add-tool node (its tools are the resource's), matching computeAIToolNodes.
const addToolRow = opts.insertable && !mod.value.agent ? 1 : 0
const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + addToolRow
const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(node.id, { ...prev, top: prev.top + space })
@@ -1,5 +1,6 @@
<script module lang="ts">
import { forbiddenIds } from '$lib/components/flows/idUtils'
import type { AgentTool } from '$lib/components/flows/agentToolUtils'
export function getToolNameError(
name: string,
@@ -43,6 +44,7 @@
| {
nodes: (Node & NodeLayout)[]
hasFlowModuleStates: boolean
linkedAgentTools: Record<string, AgentTool[]> | undefined
ret: ReturnType<typeof computeAIToolNodes>
}
| undefined
@@ -71,7 +73,10 @@
nodes: (Node & NodeLayout)[],
eventHandlers: GraphEventHandlers,
insertable: boolean,
flowModuleStates: Record<string, GraphModuleState> | undefined
flowModuleStates: Record<string, GraphModuleState> | undefined,
// Tools resolved from linked agents' resources, keyed by agent module id. Linked steps carry
// no tools of their own, so their tool nodes come from here.
linkedAgentTools?: Record<string, AgentTool[]>
): {
toolNodes: (Node & NodeLayout)[]
toolEdges: Edge[]
@@ -79,7 +84,8 @@
if (
computeAIToolNodesCache &&
!!flowModuleStates === computeAIToolNodesCache.hasFlowModuleStates &&
deepEqual(nodes.map(getComparableNode), computeAIToolNodesCache.nodes)
deepEqual(nodes.map(getComparableNode), computeAIToolNodesCache.nodes) &&
deepEqual(linkedAgentTools, computeAIToolNodesCache.linkedAgentTools)
) {
return computeAIToolNodesCache.ret
}
@@ -92,12 +98,18 @@
// by default we assume we will show tools above
let baseOffset = -AI_TOOL_BASE_OFFSET
let rowOffset = -AI_TOOL_ROW_OFFSET
// A linked step's tools come from its resource (resolved into linkedAgentTools), not the
// module, whose own `tools` is empty.
const isLinkedAgent = !!node.data.module.value.agent
const sourceTools = isLinkedAgent
? (linkedAgentTools?.[node.data.module.id] ?? [])
: (node.data.module.value.tools ?? [])
let tools: {
id: string
name: string
type?: string
stateType?: GraphModuleState['type']
}[] = node.data.module.value.tools.map((t, idx) => {
}[] = sourceTools.map((t, idx) => {
// Handle FlowModule, MCP, and Websearch tools
const toolType =
t.value.tool_type === 'mcp'
@@ -144,7 +156,12 @@
})
}
const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (insertable ? 1 : 0) // + 1 for add tool node when insertable
// A linked agent shows no "add tool" node, so its rows must not reserve one; otherwise the
// tools float up by a row, leaving a gap above the agent where the add node would have been.
// When its tools aren't resolved (e.g. viewers that don't fetch the resource), it simply
// shows none — the node label already carries the linked resource path.
const showAddToolNode = insertable && !isLinkedAgent
const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (showAddToolNode ? 1 : 0)
const siblingNames = tools.map((t) => t.name)
const toolNodes: (Node & AiToolN)[] = tools.map((tool, i) => {
@@ -153,7 +170,7 @@
const row = Math.floor(i / MAX_TOOLS_PER_ROW) + 1
const isLastRow = insertable ? row === totalRows - 1 : row === totalRows
const isLastRow = showAddToolNode ? row === totalRows - 1 : row === totalRows
return {
type: 'aiTool' as const,
parentId: node.id,
@@ -168,7 +185,13 @@
: getToolNameError(tool.name, tool.type, siblingNames),
eventHandlers,
moduleId: tool.id,
// A linked agent's tools are display-only: their resource-owned ids are not
// flow-unique, so they must not drive selection. Clicking one selects the agent
// step instead. Kept separate from moduleId — aliasing the module id here would
// misroute agent-node clicks into the graph's manual aiTool selection path.
selectTarget: isLinkedAgent && !agentActions ? node.id : undefined,
insertable,
readOnly: isLinkedAgent,
flowModuleStates
},
id: `${node.id}-tool-${tool.id}`,
@@ -205,7 +228,9 @@
allToolEdges.push(...(toolEdges ?? []))
allToolNodes.push(...(toolNodes ?? []))
if (insertable) {
// A linked agent is rigid: its tools come from the resource and can't be edited here, so
// don't offer the "add tool" node (unlink/fork the step to change tools).
if (showAddToolNode) {
allToolNodes.push({
type: 'newAiTool',
data: { eventHandlers, agentModuleId: node.data.module.id },
@@ -229,6 +254,7 @@
computeAIToolNodesCache = {
nodes: nodes.map(getComparableNode),
hasFlowModuleStates: !!flowModuleStates,
linkedAgentTools: $state.snapshot(linkedAgentTools),
ret
}
return ret
@@ -267,9 +293,20 @@
let colorClasses = $derived(
getNodeColorClasses(
data.nameError ? 'Failure' : flowModuleState?.type,
selectionManager?.getSelectedId() === data.moduleId
selectionManager?.getSelectedId() === (data.selectTarget ?? data.moduleId)
)
)
// Display-only tools (linked agents) select their agent step instead of themselves. The manager
// is set here rather than via the graph's select handler: this click never creates a svelte-flow
// node selection (the tool isn't selectable), so a manual select is safe, whereas routing the
// agent's module id through the manual path would race the agent node's own click selection.
function onSelect() {
if (data.selectTarget) {
selectionManager?.selectId(data.selectTarget)
}
data.eventHandlers.select(data.selectTarget ?? data.moduleId)
}
</script>
<NodeWrapper nodeId={id}>
@@ -287,7 +324,7 @@
colorClasses.text,
colorClasses.bg
)}
onclick={() => data.eventHandlers.select(data.moduleId)}
onclick={onSelect}
>
{#if data.moduleId.startsWith(AI_TOOL_MESSAGE_PREFIX)}
<MessageCircle size={16} class="ml-1 shrink-0" />
@@ -307,7 +344,7 @@
{data.tool || 'Missing name'}
</span>
</button>
{#if data.insertable}
{#if data.insertable && !data.readOnly}
<button
class={twMerge(
'absolute -top-[8px] -right-[8px] rounded-full h-[16px] w-[16px] center-center text-secondary outline-[1px] outline dark:outline-gray-500 outline-gray-300 bg-surface duration-0 hover:bg-red-400 hover:text-white !hidden',
@@ -36,7 +36,7 @@ export function makeFlowCodec(
// stateStore is keyed by module_id; after an AI write the set of
// module ids may differ, so rebuild the UI state. This wipes per-module
// test args / preview output — a known v1 trade-off.
void initFlowState(store.val, stateStore, workspace)
void initFlowState(store.val, stateStore, workspace, store.val.path ?? '')
},
storeToDraft() {
return store.val
+21 -3
View File
@@ -1073,10 +1073,10 @@ components:
description: |
Number. Limits how many times the agent can loop through reasoning and tool use.
Range: 1-1000.
# Only the flow-local inputs are always present: a step linked to an `ai_agent` resource
# (see `agent`) keeps just those and takes provider/output_type from the resource.
required:
- provider
- user_message
- output_type
tools:
type: array
description: Array of tools the agent can use. The agent decides which tools to call based on the task
@@ -1093,11 +1093,29 @@ components:
type: boolean
default: false
description: If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.
agent:
type: string
description: |
Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain
config (provider/model/system prompt/etc.) and tool set are resolved at runtime from
that resource; the module's input_transforms then only carry the flow-local inputs
(user_message/user_attachments).
tool_inputs:
type: object
description: |
Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the
referenced agent's tools to this flow's context (flow_input/results) without mutating the
shared resource; overlaid onto the tools' input_transforms at runtime — including when
`agent` is unset, since a step forked for editing keeps these overrides until it is saved
back or unlinked.
additionalProperties:
type: object
additionalProperties:
$ref: '#/components/schemas/InputTransform'
parallel:
type: boolean
description: If true, the agent can execute multiple tool calls in parallel
required:
- tools
- type
- input_transforms
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long