feat: agents as a standalone kind with home listing, detail and editor pages (#11332)

* feat: list agents on the home page and create them from the new menu

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

* fix: keep runnables out of the agents view and anchor a new agent once saved

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

* fix: autosave a new agent's first edit and list agents past one page

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

* feat: add agent detail and editor pages

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

* fix: explain when an agent cannot be run and drop the broken agent move

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

* fix: keep query params and a unique path when creating an agent

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

* fix: lead the home list with agents and keep rows while the agent view loads

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

* fix: treat a loading agent as undeployed when leaving the editor page

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

* feat: agent detail page config panel, run page on form runs, chat badge

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

* feat: agent configuration modal and draft paths like other new items

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

* fix: keep a new agent draft-free until the first input, land agents with runnables

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

* feat: place AI agent after apps in the new menu and describe chat and flow use

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

* feat: new agents start with managed memory, editor form says how to turn it off

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

* fix: clearer managed memory hint in the agent editor form

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

* refactor: share the agent editor's pane notice as a PaneNotice component

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

* fix: name a new agent after a path no resource holds

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

* fix: tell repeated tool names apart and hide permissions on draft-only agents

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

* feat: agent configuration beside the model in the chat composer and above the form

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

* feat: center the agent run form, configuration beside its Run button

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

* fix: icon-only agent configuration button beside Run

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

* feat: agents run on behalf of their deployer through a run-by-path endpoint

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

* feat: agents keep their run-as identity in their value, preserved by the CLI

The identity an agent runs as lives in `value.on_behalf_of` instead of a column. Every write
of an agent resolves it server-side as a flow's is: the writer's own, unless an admin or
wm_deployers member asks to keep it, and a folder default on create. Retyping a resource into
an agent resolves its value the same way. Export leaves it out; the run endpoint reads it and
drops it from the step's inputs.

The CLI follows the app model: a pushed agent never takes its identity from the tracked file,
an unchanged agent compares equal to the deployed one, and an admin or deployer push claims
the deployed identity back. The owner-change pre-check lists agents.

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

* feat: deploying an agent draft from review keeps its deployed identity

As for an app: an agent draft carries no identity, so the review page claims the deployed one
back, which the backend honours for an admin or wm_deployers member. The draft diff leaves the
deployed identity out, since a draft never holds one.

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

* feat: deploying an agent to another workspace offers the run-as choice

An agent deployed to prod/staging, merged from a fork or promoted with `wmill workspace merge`
gets the same identity choice as a flow or an app: the target's current one, the deployer, or
a picked user, sent as a principal the way a trigger's is. The source workspace's principal is
never copied, and a difference in identity alone is not a change in the workspace compare or
its diff.

The frontend consumes the published windmill-utils-internal, so its deploy provider carries the
same rewrite until that version ships.

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

* fix: agent identity on fork, agent-scoped job reads, and the run license gate

A fork re-points an agent's identity at its creator when they may not preserve someone else's,
and at the creator when it names nobody in the fork, as it does an app's. A token scoped to
`jobs:run:agents:<path>` reads back the runs it starts, chat turns included. The agent run
endpoint checks the enterprise license like every other run entrypoint.

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

* fix: a CLI push decides agent identity handling by the tracked file's type

An agent retyped into another resource by a push kept neither its value's `on_behalf_of` as the
file stated it nor clear of the old agent's identity. The file's type now decides, and only a
deployed agent's identity is claimed back.

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

* refactor: agents run as the caller, with a note on what a shared one needs

Drops the agent run-as identity: its storage in the agent value, the backfill, the resolution
on every write, and its handling in export, the CLI, draft and cross-workspace deploys, forks
and the workspace compare. The run endpoint runs as the caller, so an operator or reader runs an
agent with their own access. The editor tells the author of a folder agent that anyone running
it needs access to its AI resource and to what its tools use.

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

* docs: agent editor comments describe runs as the caller

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

* refactor: agent editor pane notes use Alert

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

* refactor: agent editor pane notes render as Alert

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

* fix: agent editor notes as regular Alerts, not banners

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

* feat: agent path as its own editor level, and evals on the agent page

The path leaves the agent form: the editor dialog opens it as a level, as it does evals, and the
editor page in a drawer. The agent page gains Evals, in a dialog. The page supplies the run
form, keeping it out of the editor the flow editor reaches.

The draft edit gate no longer throws when a focused, changed field is removed: the `change` that
removal fires lands mid-teardown, so the gate opens just after instead.

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

* feat: agent settings as the resource editor's header fields, behind a cog

The agent's own settings (path, labels, workspace specific, description) open from a cog as
the flow and script editors' do, laid out as the top of the resource editor. The folder note is
gone.

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

* feat: agent settings fields and cog, completing the rename

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

* feat: evals open as a dialog over the agent editor page

The editor page opens an agent's evals over itself, as the agent page does, with the unsaved
edits offered to a run; the editor dialog keeps evals as a level of its own. The two pages share
the dialog.

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

* fix: the unreachable model provider note reads like the unreachable agent one

Same warning level and wording as the note above it, with the path inline rather than in
parentheses that lost their spaces.

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

* fix: the unreachable model provider note offers editing the agent too

Editing the agent to use a provider the reader can access is often the simpler way out; offered
when the reader can write the agent, with Unlink and asking for access.

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

* fix: provider note lists asking for access as its own option

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

* fix: shorter provider note, without the header's buttons repeated

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

* fix: evals only for those who can edit the agent

Evaluating builds datasets and runs against the agent, which is authoring: the agent page and the
editor offer it only with write access to the agent.

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

* fix: agent page actions ordered as the script and flow pages

The menu leads and Edit comes last, as DetailPageHeader lays them out.

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

* fix: the agent editor dialog's levels slide in built on the first visit

Warmed, as the evals pane's levels are, so settings and evals are mounted before the first
navigation rather than inside its transition. Evals are only in the strip where they can be
opened.

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

* fix: the agent's settings report path errors, and hold nothing a reader can edit

The path field reads its error back, so it shows it and keeps deploy blocked on it.
Labels are shown rather than editable without write access. Evals wait for the load
to know they can be opened, and the page layout builds no levels it never shows.

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

* refactor: one builder for an agent's run flow, and the agent page on the shared header

The run endpoint and evals build the agent's one-step flow through the same function.
The agent page uses DetailPageHeader, whose error handler, tag and trigger context are
now optional, and whose menu items keep their disabled state. The home row and the
page share the agent's menu and delete.

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

* fix: the agent page's menu is built from its current path

Deploy settings are the workspace's, so they load once and the menu is derived from
them rather than fetched per path, where a superseded fetch could land after a
navigation. The shared run-flow builder lives with agent runs rather than evals.

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

* fix: the scoped-read comment names the run-flow builder as it is

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-30 18:40:05 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ca44043e12
commit 8c7dcbbda5
40 changed files with 2108 additions and 434 deletions
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT\n CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\\.chat$', '')\n ELSE j.runnable_path END AS runnable_path,\n CASE\n WHEN a.agent THEN 'agents'\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id,\n LATERAL (SELECT j.kind = 'flowpreview'\n AND j.raw_flow->'modules'->1 IS NULL\n AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a\n WHERE j.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "scope_kind",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "launched_by_app",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null,
null,
null
]
},
"hash": "7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec"
}
@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT j.runnable_path,\n CASE\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id\n WHERE j.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "scope_kind",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "launched_by_app",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
true,
null,
null
]
},
"hash": "84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\"\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value: sqlx::types::Json<serde_json::Value>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c"
}
+26
View File
@@ -65,6 +65,32 @@ INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, resu
('15151515-1515-1515-1515-151515151515', 'test-workspace', 1000, 'success'::job_status,
'{"wrapped": "WRAPPED_FLOW_RESULT"}');
-- Scoped to an agent. An agent run is a preview of the one-step flow the run endpoint builds
-- (step `__wm_agent_root`), filed under the agent's path, or `<path>.chat` for a chat turn.
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
encode(sha256('RUN_SCOPED_AGENT_TOKEN'::bytea), 'hex'), 'RUN_AGENT', 'RUN_SCOPED_AGENT_TOKEN',
'test2@windmill.dev', 'agent token', false,
ARRAY['jobs:run:agents:f/shared/agent1']
);
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, raw_flow
) VALUES
('17171717-1717-1717-1717-171717171717', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flowpreview', 'deno', 'f/shared/agent1', 'flow', true,
'{"modules": [{"id": "__wm_agent_root", "value": {"type": "aiagent", "tools": []}}]}'),
('18181818-1818-1818-1818-181818181818', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flowpreview', 'deno', 'f/shared/agent1.chat', 'flow', true,
'{"modules": [{"id": "__wm_agent_root", "value": {"type": "aiagent", "tools": []}}]}');
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
('17171717-1717-1717-1717-171717171717', 'test-workspace', 1000, 'success'::job_status,
'{"agent": "AGENT_RESULT"}'),
('18181818-1818-1818-1818-181818181818', 'test-workspace', 1000, 'success'::job_status,
'{"agent": "AGENT_CHAT_RESULT"}');
-- A token pairing an app scope with a run scope, as someone driving an app's components
-- programmatically would build. `APP_INLINE_JOB` is an inline-script component run: no
-- `jobs:run` scope can name its kind, so only the `apps:run` half puts it in reach.
+20
View File
@@ -456,6 +456,26 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
);
}
// An agent run and a chat turn of it belong to the agent, readable by the token scoped to
// it and by no flow-scoped one.
for (job, expected) in [
("17171717-1717-1717-1717-171717171717", "AGENT_RESULT"),
("18181818-1818-1818-1818-181818181818", "AGENT_CHAT_RESULT"),
] {
let path = format!("completed/get_result/{job}");
let (status, body) = get(&base, &path, Some("RUN_SCOPED_AGENT_TOKEN")).await;
assert!(
status.is_success() && body.contains(expected),
"agent-scoped token must read its agent's run {job} (got {status}): {body}"
);
let (status, body) = get(&base, &path, Some("RUN_SCOPED_TOKEN")).await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"a flow-scoped token must not read an agent run {job} (got {status}): {body}"
);
}
// An `apps:run:<app>` scope is a start grant too: the inline-script component run it
// launched — a kind no `jobs:run` scope can name — stays readable to a token scoped
// to that app, and stays out of reach for one that is only scoped to run jobs.
+14 -2
View File
@@ -621,12 +621,17 @@ const FLOW_JOBS: [&'static str; 6] = [
"jobs/run_and_stream/f",
];
/// Runs of a saved AI agent: the server builds the run from the stored agent, so the route is
/// scoped by the agent's path like a deployed runnable's, as `jobs:run:agents:<path>`.
const AGENT_JOBS: [&'static str; 1] = ["jobs/run/agent"];
lazy_static::lazy_static! {
static ref RUN_PATH_ACTIONS: Vec<&'static str> = {
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component", "apps_u/upload_s3_file"];
v.extend(SCRIPT_JOBS);
v.extend(FLOW_JOBS);
v.extend(AGENT_JOBS);
v
};
}
@@ -646,13 +651,13 @@ fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction {
}
}
/// Checks the route path to determine the runnable kind (either "flows" or "scripts").
/// Checks the route path to determine the runnable kind ("agents", "flows" or "scripts").
///
/// The order of checks is important:
/// - Flow-related paths are checked first to avoid false positives, as some flow paths
/// (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`).
///
/// Returns `"flows"` or `"scripts"` based on the match, or `None` if no match is found.
/// Returns the kind based on the match, or `None` if no match is found.
fn determine_kind_from_route(route_path: &str) -> Option<String> {
if route_path.starts_with("jobs") {
// Preview/bundle runs execute arbitrary code with no deployed path, so
@@ -666,6 +671,9 @@ fn determine_kind_from_route(route_path: &str) -> Option<String> {
{
return None;
}
if AGENT_JOBS.iter().any(|path| route_path.starts_with(path)) {
return Some("agents".to_string());
}
if FLOW_JOBS.iter().any(|path| route_path.starts_with(path)) {
return Some("flows".to_string());
} else if SCRIPT_JOBS.iter().any(|path| route_path.starts_with(path)) {
@@ -1725,6 +1733,10 @@ mod tests {
scope_for_route("POST", "/api/w/ws/jobs/run/f/u/x/y").as_deref(),
Some("jobs:run:flows")
);
assert_eq!(
scope_for_route("POST", "/api/w/ws/jobs/run/agent/u/x/y").as_deref(),
Some("jobs:run:agents")
);
// Preview/bundle runs have no deployed path and their handlers require the
// broad `jobs:run` scope, so the derived scope must not carry a kind.
@@ -0,0 +1,60 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const AGENT: &str = "u/test-user-3/agent";
async fn run(base_url: &str, token: &str) -> anyhow::Result<(u16, String)> {
let resp = reqwest::Client::new()
.post(format!(
"{base_url}/w/test-workspace/jobs/run/agent/{AGENT}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&json!({ "user_message": "hi" }))
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
}
/// Reading an agent is what allows running it, and the run is the caller's own: `test-user-2`, a
/// plain member, runs `test-user-3`'s agent once it may read it, lending nobody's permissions.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_agent_run_needs_read_and_runs_as_caller(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!("http://localhost:{}/api", server.addr.port());
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', $1, $2, 'ai_agent', '{}', 'test-user-3')",
)
.bind(AGENT)
.bind(json!({ "system_prompt": "hi" }))
.execute(&db)
.await?;
let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?;
assert_eq!(status, 404, "an unreadable agent: {body}");
sqlx::query(
"UPDATE resource SET extra_perms = '{\"u/test-user-2\": false}'
WHERE workspace_id = 'test-workspace' AND path = $1",
)
.bind(AGENT)
.execute(&db)
.await?;
let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?;
assert_eq!(status, 201, "a readable agent: {body}");
let (permissioned_as, created_by) = sqlx::query_as::<_, (String, String)>(
"SELECT permissioned_as, created_by FROM v2_job WHERE id = $1::uuid",
)
.bind(&body)
.fetch_one(&db)
.await?;
assert_eq!(permissioned_as, "u/test-user-2");
assert_eq!(created_by, "test-user-2");
Ok(())
}
+52
View File
@@ -16180,6 +16180,47 @@ paths:
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
"user_seq" } }`.
/w/{workspace}/jobs/run/agent/{path}:
post:
summary: run a saved AI agent
description: |
Runs the `ai_agent` resource at `path` as stored, for any caller who can read it
(operators included), as the caller, who needs access to the agent's AI resource and to
whatever its tools use. With `memory_id`, the run is a turn
of the caller's conversation with the agent; without, a single run.
operationId: runAgent
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- name: memory_id
description: The conversation this turn belongs to. A uuid is used as is; any other string is hashed within the workspace and agent.
in: query
schema:
type: string
requestBody:
description: the run's inputs
required: true
content:
application/json:
schema:
type: object
properties:
user_message:
type: string
user_attachments:
type: array
items: {}
responses:
"201":
description: job created
content:
text/plain:
schema:
type: string
format: uuid
/w/{workspace}/jobs/run_wait_result/preview_flow:
post:
summary: run flow preview and wait for result
@@ -31991,6 +32032,17 @@ components:
(over a deployed row or a synthesized draft-only row).
Frontend appends a `*` to the displayed name.
type: boolean
agent_memory:
description: |
For an `ai_agent` resource only, its `memory` setting: the one part
of the value a listing returns, since it decides whether the agent
keeps a conversation.
draft_path:
type: string
description: |
On a draft-only row, the path its editor has staged when it
differs from the storage path (e.g. a never-deployed item parked at
`u/{user}/draft_{uuid}`).
required:
- path
- resource_type
+184
View File
@@ -0,0 +1,184 @@
//! Running a saved AI agent from its own page, and the one-step flow its runs and evals share.
//!
//! The run is built here from the stored agent rather than sent by the client, which is what lets
//! anyone who can read the agent run it, operators included, the way a deployed flow runs by path:
//! a preview takes arbitrary code, this takes only the run's inputs. It runs as the caller, who
//! needs access to the agent's AI resource and to whatever its tools use.
use std::collections::HashMap;
use axum::{
extract::{Path, Query},
Extension, Json,
};
use hyper::StatusCode;
use serde_json::value::RawValue;
use windmill_api_auth::check_scopes;
use windmill_common::{
db::UserDB,
error::{Error, Result},
jobs::JobPayload,
users::username_to_permissioned_as,
utils::{not_found_if_none, StripPath},
};
use windmill_queue::{push, PushArgs, PushIsolationLevel};
use crate::ai_evals::run::config_to_draft;
use crate::ai_evals::subject::AgentDraft;
use crate::db::{ApiAuthed, DB};
use crate::jobs::{handle_chat_conversation_messages, set_flow_memory_id, RunJobQuery};
/// The id the agent's step carries in the run, as the editor's chat names it. A token scoped to
/// `jobs:run:agents:<path>` reads its runs back by it (`require_job_within_run_scope`).
const AGENT_NODE_ID: &str = "__wm_agent_root";
/// A turn of the agent's chat when `memory_id` names a conversation, a single run otherwise.
/// Returns the job id.
pub(crate) async fn run_agent(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
Json(args): Json<HashMap<String, Box<RawValue>>>,
) -> Result<(StatusCode, String)> {
#[cfg(feature = "enterprise")]
crate::jobs::check_license_key_valid().await?;
let path = path.to_path();
check_scopes(&authed, || format!("jobs:run:agents:{path}"))?;
// Read through the caller's own permissions: reading the agent is what allows running it.
let mut tx = user_db.clone().begin(&authed).await?;
let value = sqlx::query_scalar!(
"SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\"
FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
w_id,
path
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let Some(sqlx::types::Json(value)) = not_found_if_none(value, "Agent", path)? else {
return Err(Error::BadRequest(format!(
"Agent {path} has no configuration"
)));
};
let config = config_to_draft(value)?;
// A chat turn is filed where the agent's chat lists its conversations, which a flow cannot
// name (flow paths carry no `.`), so the two never share a conversation list.
let chat = run_query.memory_id.is_some();
let run_path = if chat {
format!("{path}.chat")
} else {
path.to_string()
};
// A step memory id would replace the conversation's, and the agent would forget the turns
// before; history comes from the conversation alone.
let flow_value = agent_step_flow(
&config,
AGENT_NODE_ID,
chat,
&["memory_id", "previous_messages"],
)?;
let push_authed = authed.clone().into();
let (uuid, mut tx) = push(
&db,
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
&w_id,
JobPayload::RawFlow {
value: flow_value,
path: Some(run_path.clone()),
restarted_from: None,
},
PushArgs::from(&args),
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
Some(&push_authed),
false,
None,
authed.trigger_or_fallback(None),
None,
)
.await?;
if let Some(memory_id) = run_query.memory_key(&w_id, &run_path) {
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
}
if chat {
// A real conversation, not a test one: this is the agent as deployed, not a draft.
handle_chat_conversation_messages(
&mut tx,
&authed,
&w_id,
&run_path,
&run_query,
args.get("user_message"),
uuid,
false,
&args,
)
.await?;
}
tx.commit().await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
/// The agent as a one-step flow, validated by deserializing through `FlowValue` rather than
/// trusted as raw JSON. Its own transforms are the step's, minus `without`, and the run's inputs
/// supply the message and the attachments over the top. Always inlined, never a link to the
/// resource: a linked step would resolve the agent when it runs, not as it was read here.
pub(crate) fn agent_step_flow(
config: &AgentDraft,
id: &str,
chat: bool,
without: &[&str],
) -> Result<windmill_common::flows::FlowValue> {
let mut input_transforms = match &config.input_transforms {
serde_json::Value::Object(map) => map.clone(),
_ => serde_json::Map::new(),
};
for key in without {
input_transforms.remove(*key);
}
for key in ["user_message", "user_attachments"] {
input_transforms.insert(
key.to_string(),
serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }),
);
}
let mut flow = serde_json::json!({
"modules": [{
"id": id,
"value": {
"type": "aiagent",
"tools": config.tools,
"input_transforms": serde_json::Value::Object(input_transforms),
}
}]
});
if chat {
flow["chat_input_enabled"] = serde_json::json!(true);
}
Ok(serde_json::from_value(flow)?)
}
+2 -32
View File
@@ -248,7 +248,7 @@ fn build_run_flow(
/// The agent step, reading its case from the iteration rather than from the flow's arguments.
fn agent_module(config: &AgentDraft) -> Result<serde_json::Value> {
let flow = build_case_flow(config)?;
let flow = crate::agent_runs::agent_step_flow(config, AGENT_NODE_ID, false, &[])?;
let mut value = serde_json::to_value(&flow.modules[0].value)?;
if let Some(map) = value.as_object_mut() {
let transforms = map
@@ -269,36 +269,6 @@ fn agent_module(config: &AgentDraft) -> Result<serde_json::Value> {
Ok(serde_json::json!({ "id": AGENT_NODE_ID, "value": value }))
}
/// The agent step as a one-module flow, so the module shape is validated by deserializing
/// through `FlowValue` rather than trusted as raw JSON.
fn build_case_flow(config: &AgentDraft) -> Result<windmill_common::flows::FlowValue> {
// The configuration runs exactly as authored: its own brain transforms are the module's, and
// the case supplies the message and the attachments over the top.
let mut input_transforms = match &config.input_transforms {
serde_json::Value::Object(map) => map.clone(),
_ => serde_json::Map::new(),
};
for key in ["user_message", "user_attachments"] {
input_transforms.insert(
key.to_string(),
serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }),
);
}
// Always inlined, never a link to the resource: a linked step would resolve the agent when
// each case runs, which is the one thing a run of a named version must not do.
let mut agent_value = serde_json::Map::new();
agent_value.insert("type".to_string(), serde_json::json!("aiagent"));
agent_value.insert("tools".to_string(), serde_json::json!(config.tools));
agent_value.insert(
"input_transforms".to_string(),
serde_json::Value::Object(input_transforms),
);
Ok(serde_json::from_value(serde_json::json!({
"modules": [{ "id": AGENT_NODE_ID, "value": serde_json::Value::Object(agent_value) }]
}))?)
}
/// How many times the agent has been saved, not the identity of the row holding that value: runs
/// are named by it and compared by it, so it has to be the resource's own count rather than a
/// sequence the whole instance shares.
@@ -348,7 +318,7 @@ pub(crate) async fn require_agent(
/// transforms, its tools the module's tools. The same conversion for a draft and for what is
/// deployed, so the two hash comparably — which is what lets a draft run be recognised as the
/// version it became.
fn config_to_draft(value: serde_json::Value) -> Result<AgentDraft> {
pub(crate) fn config_to_draft(value: serde_json::Value) -> Result<AgentDraft> {
let mut config = match value {
serde_json::Value::Object(map) => map,
_ => return Err(Error::BadRequest("The agent is not an object".to_string())),
+13 -3
View File
@@ -285,6 +285,7 @@ pub fn workspaced_service() -> Router {
)
.route("/add_batch_jobs/{n}", post(add_batch_jobs))
.route("/run/preview_flow", post(run_preview_flow_job))
.route("/run/agent/{*path}", post(crate::agent_runs::run_agent))
.route(
"/run_wait_result/preview_flow",
post(run_wait_result_preview_flow),
@@ -1897,7 +1898,10 @@ async fn require_job_within_run_scope(
// NULL for a job no such scope reaches directly (previews, dependency jobs,
// flow-inlined scripts) — those are still readable as a step of a matching flow,
// through their ancestors. A `singlestepflow` wraps either a script or a flow, so it
// projects onto the wrapped runnable the same way the batch-rerun query does.
// projects onto the wrapped runnable the same way the batch-rerun query does. An agent
// run is a preview of the one-step flow `agent_runs::agent_step_flow` builds, filed under the
// agent's path (`<path>.chat` for a chat turn); the editor's own runs of it look the same,
// and are runs of that agent too.
let chain = sqlx::query!(
r#"WITH RECURSIVE chain(id, parent_job) AS (
SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2
@@ -1905,8 +1909,11 @@ async fn require_job_within_run_scope(
SELECT j.id, j.parent_job FROM v2_job j
JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2
)
SELECT j.runnable_path,
SELECT
CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\.chat$', '')
ELSE j.runnable_path END AS runnable_path,
CASE
WHEN a.agent THEN 'agents'
WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'
WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'
WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN
@@ -1919,7 +1926,10 @@ async fn require_job_within_run_scope(
) = 'flow' THEN 'flows' ELSE 'scripts' END
END AS scope_kind,
CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app
FROM v2_job j JOIN chain c ON c.id = j.id
FROM v2_job j JOIN chain c ON c.id = j.id,
LATERAL (SELECT j.kind = 'flowpreview'
AND j.raw_flow->'modules'->1 IS NULL
AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a
WHERE j.workspace_id = $2"#,
job_id,
w_id,
+1
View File
@@ -124,6 +124,7 @@ pub mod storage_list_ee;
mod storage_list_oss;
mod workspace_dependencies;
mod agent_runs;
mod ai_evals;
mod approvals;
#[cfg(all(feature = "enterprise", feature = "private"))]
+41 -11
View File
@@ -208,6 +208,25 @@ pub struct ListableResource {
pub is_draft: Option<bool>,
}
/// A row of the resource listing: the resource as a single read returns it, plus what only the
/// listing carries.
#[derive(FromRow, Serialize)]
pub struct ListedResource {
#[sqlx(flatten)]
#[serde(flatten)]
pub resource: ListableResource,
/// An `ai_agent`'s `memory` setting, the one part of its value a listing shows: whether it
/// keeps a conversation decides how it is offered. `value` stays unlisted for every type.
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub agent_memory: Option<serde_json::Value>,
/// On a draft-only row, the path its editor has staged when it differs from the storage path
/// (a new item parked at `u/{user}/draft_{uuid}`), as the other kinds' listings report it.
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub draft_path: Option<String>,
}
#[derive(Deserialize)]
pub struct CreateResource {
pub path: String,
@@ -324,7 +343,7 @@ async fn list_resources(
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<ListableResource>> {
) -> JsonResult<Vec<ListedResource>> {
let (per_page, offset) = paginate(pagination);
let mut sqlb = SqlBuilder::select_from("resource")
@@ -346,6 +365,7 @@ async fn list_resources(
"resource.labels",
"folder_labels(resource.workspace_id, resource.path) as inherited_labels",
"ws_specific.path IS NOT NULL as ws_specific",
"CASE WHEN resource.resource_type = 'ai_agent' THEN resource.value->'memory' END as agent_memory",
])
// Scalar EXISTS flags the authed user's per-user draft without fanning rows out.
.field(
@@ -430,11 +450,11 @@ async fn list_resources(
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let allowed = build_scope_path_predicate(&authed, "resources", "read");
let mut rows = sqlx::query_as::<_, ListableResource>(&sql)
let mut rows = sqlx::query_as::<_, ListedResource>(&sql)
.fetch_all(&mut *tx)
.await?
.into_iter()
.filter(|r| allowed(&r.path))
.filter(|r| allowed(&r.resource.path))
.collect::<Vec<_>>();
tx.commit().await?;
@@ -469,14 +489,18 @@ async fn list_resources(
let v: serde_json::Value =
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
// ResourceEditor's `ResourceState`: { path, description, args, labels?, wsSpecific, resource_type? }
let path = v
.get("path")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
if path.is_empty() || !allowed(&path) {
// Listed at the draft's key rather than its `path`: an editor keys the draft on the path
// it opened, which is the only one a draft read can find, while `path` moves on rename.
// The staged `path` is reported beside it as `draft_path`.
let path = row.path;
if !allowed(&path) {
continue;
}
let draft_path = v
.get("path")
.and_then(|s| s.as_str())
.filter(|p| !p.is_empty() && *p != path)
.map(str::to_string);
let description = v
.get("description")
.and_then(|x| x.as_str())
@@ -507,8 +531,13 @@ async fn list_resources(
})
});
let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool());
let agent_memory = if resource_type == "ai_agent" {
value.as_ref().and_then(|a| a.get("memory")).cloned()
} else {
None
};
rows.push(ListableResource {
let resource = ListableResource {
workspace_id: w_id.clone(),
path,
value,
@@ -530,7 +559,8 @@ async fn list_resources(
draft_only: Some(true),
// Synthesized rows are the authed user's draft.
is_draft: Some(true),
});
};
rows.push(ListedResource { resource, agent_memory, draft_path });
}
}
+21 -1
View File
@@ -123,6 +123,9 @@
args?: Record<string, any>
jsonView?: boolean
isValid?: boolean
/** Controls beside the Run button of a form that cannot schedule, in the row a
* schedulable one gives its Advanced options. */
actions?: import('svelte').Snippet
}
let {
@@ -141,7 +144,8 @@
overrideTagNote = undefined,
args = $bindable(),
jsonView = false,
isValid = $bindable(true)
isValid = $bindable(true),
actions = undefined
}: Props = $props()
let showPsCommonParams = $derived(
@@ -385,6 +389,22 @@
</div>
{/if}
</div>
{:else if actions}
<!-- The schedulable row's layout, with the caller's controls where Advanced sits. -->
<div class="flex-row-reverse flex-wrap flex w-full gap-4 mt-2 md:mt-6">
<Button
{loading}
variant="accent"
unifiedSize="md"
btnClasses="!inline-flex"
disabled={!isValid && !jsonView}
on:click={() => run(null)}
shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }}
>
{buttonText}
</Button>
<div>{@render actions()}</div>
</div>
{:else}
<Button
btnClasses="!px-6 !py-1 w-full"
@@ -0,0 +1,122 @@
<script lang="ts">
import { base } from '$lib/base'
import Dropdown from '$lib/components/DropdownV2.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import type { ListableResource } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { Pen } from 'lucide-svelte'
import Button from '../button/Button.svelte'
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import Row from './Row.svelte'
/**
* A home-page row for a saved agent. The agent is an `ai_agent` resource, so the row opens the
* agent editor the resources page hosts, and its menu carries the resource actions.
*/
interface Props {
agent: ListableResource & { canWrite: boolean }
marked: string | undefined
shareModal: ShareModal
deploymentDrawer: DeployWorkspaceDrawer
deleteConfirmedCallback?: (() => void) | undefined
depth?: number
menuOpen: boolean
keyboardSelected?: boolean
}
let {
agent,
marked,
shareModal,
deploymentDrawer,
deleteConfirmedCallback = $bindable(),
depth = 0,
menuOpen = $bindable(),
keyboardSelected = false
}: Props = $props()
const dispatch = createEventDispatcher()
async function remove(path: string) {
if (await deleteAgent($workspaceStore!, path)) dispatch('change')
}
let editHref = $derived(`${base}/agents/edit/${agent.path}`)
// A draft-only agent has nothing deployed for its page to run, so it opens in the editor.
let rowHref = $derived(agent.draft_only ? editHref : `${base}/agents/get/${agent.path}`)
</script>
{#snippet chatBadge()}
<ChatFlowBadge title="Managed memory on: this agent opens as a conversation" />
{/snippet}
<Row
href={rowHref}
titleBadge={keepsManagedMemory(agent.agent_memory) ? chatBadge : undefined}
kind="agent"
{keyboardSelected}
{marked}
path={agent.draft_path ?? agent.path}
summary={agent.description}
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
canFavorite={false}
{depth}
>
{#snippet badges()}
<SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} />
<DraftBadge
is_draft={agent.is_draft}
draft_only={agent.draft_only}
currentUsername={$userStore?.username}
workspace={$workspaceStore ?? undefined}
itemKind="resource"
path={agent.path}
onMigrated={() => dispatch('change')}
/>
<InheritedLabels labels={agent.inherited_labels} />
{/snippet}
{#snippet actions()}
{#if agent.canWrite}
<span class="hidden md:inline-flex">
<Button
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
startIcon={{ icon: Pen }}
href={editHref}
>
Edit
</Button>
</span>
{/if}
<Dropdown
items={async () =>
agentMenuItems({
deployUiSettings: await getDeployUiSettings(),
path: agent.path,
canWrite: agent.canWrite,
draftOnly: Boolean(agent.draft_only),
wsSpecific: agent.ws_specific,
onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'),
onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'),
onDelete: (event) => {
const { path } = agent
if (event?.shiftKey) remove(path)
else deleteConfirmedCallback = () => remove(path)
}
})}
on:open={() => {
menuOpen = true
}}
/>
{/snippet}
</Row>
@@ -39,6 +39,7 @@
| 'flow'
| 'app'
| 'raw_app'
| 'agent'
| 'resource'
| 'variable'
| 'resource_type'
@@ -8,6 +8,7 @@
import AzureIcon from '$lib/components/icons/AzureIcon.svelte'
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
import {
Bot,
Boxes,
Calendar,
Code2,
@@ -29,6 +30,7 @@
| 'app'
| 'raw_app'
| 'raw_app_file'
| 'agent'
| 'resource'
| 'variable'
| 'resource_type'
@@ -99,6 +101,8 @@
<FileIcon name={path ?? ''} {size} />
{:else if effectiveKind === 'script'}
<Code2 {size} class="text-blue-500" />
{:else if effectiveKind === 'agent'}
<Bot {size} class="text-violet-500" />
{:else if effectiveKind === 'variable'}
<DollarSign {size} class="text-gray-400" />
{:else if effectiveKind === 'resource'}
@@ -21,10 +21,13 @@ they follow the mode, the connected servers and the workspace's AI settings.
tools,
providerTools,
active,
blocksClose = $bindable()
blocksClose = $bindable(),
description = "What the assistant can call in this session: the built-in tools, whatever the connected MCP servers expose, and what the model's provider runs itself."
}: {
tools: ToolSummary[]
providerTools: ProviderToolSummary[]
/** What the list is, under its heading. */
description?: string
/** Whether this is the panel on screen. Gates the detail page's build, which pulls
* in the schema table and its syntax highlighter. */
active: boolean
@@ -145,10 +148,7 @@ they follow the mode, the connected servers and the workspace's AI settings.
onkeydown={highlight.onKeydown}
onpointermove={highlight.pointerMoved}
>
<Section
label="Tools"
description="What the assistant can call in this session: the built-in tools, whatever the connected MCP servers expose, and what the model's provider runs itself."
>
<Section label="Tools" {description}>
<!-- Sticks to the top of the scrolling panel so a 70-row list stays searchable. -->
<div class="sticky top-0 z-10 bg-surface pb-2">
<TextInput
@@ -29,26 +29,33 @@
type MenuItemButton = {
label: string
Icon: any
onclick: () => void
onclick: (e: MouseEvent) => void
color?: 'red'
disabled?: boolean
}
const { triggersCount, triggersState } = $state(getContext<TriggerContext>('TriggerContext'))
// An agent's page has no triggers, and so no context for them.
const { triggersCount, triggersState } = $state(
getContext<TriggerContext | undefined>('TriggerContext') ?? ({} as Partial<TriggerContext>)
)
interface Props {
mainButtons?: MainButton[]
menuItems?: MenuItemButton[]
summary?: string
path?: string
tag: string | undefined
errorHandlerKind: 'flow' | 'script'
scriptOrFlowPath: string
errorHandlerMuted: boolean | undefined
tag?: string | undefined
/** Unset for what has no workspace error handler to mute, such as an agent. */
errorHandlerKind?: 'flow' | 'script'
scriptOrFlowPath?: string
errorHandlerMuted?: boolean | undefined
labels?: string[] | undefined
inheritedLabels?: string[] | undefined
onSaved?: (newPath: string) => void
children?: import('svelte').Snippet
trigger_badges?: import('svelte').Snippet
/** Controls ahead of the menu, such as the way an agent's page runs it. */
leading_actions?: import('svelte').Snippet
}
let {
@@ -64,7 +71,8 @@
inheritedLabels = undefined,
onSaved,
children,
trigger_badges
trigger_badges,
leading_actions
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -81,6 +89,7 @@
}
async function toggleErrorHandler() {
if (!errorHandlerKind || !scriptOrFlowPath) return
const next = await toggleWorkspaceErrorHandler(
errorHandlerKind,
scriptOrFlowPath,
@@ -99,7 +108,7 @@
disabled: b.buttonProps.disabled,
type: 'action' as const
})),
...(wide.current
...(wide.current || !errorHandlerKind
? []
: [
{
@@ -113,6 +122,7 @@
displayName: item.label,
icon: item.Icon,
action: item.onclick,
disabled: item.disabled,
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
separatorTop: i === 0 && !wide.current
}))
@@ -176,12 +186,13 @@
{@render trigger_badges?.()}
</div>
<div class="flex gap-1 items-center pr-4">
{@render leading_actions?.()}
{#if allMenuItems.length > 0}
{#key allMenuItems}
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
{/key}
{/if}
{#if wide.current}
{#if wide.current && errorHandlerKind && scriptOrFlowPath}
<ErrorHandlerToggleButton
kind={errorHandlerKind}
{scriptOrFlowPath}
@@ -0,0 +1,55 @@
import { FileUp, Shield, Trash } from 'lucide-svelte'
import { ResourceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import type { Item } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
import type { WorkspaceDeployUISettings } from '$lib/gen'
/** Deletes a saved agent, saying how it went. Resolves whether it was deleted. */
export async function deleteAgent(workspace: string, path: string): Promise<boolean> {
try {
await ResourceService.deleteResource({ workspace, path })
sendUserToast(`Deleted agent ${path}`)
return true
} catch (err) {
sendUserToast(`Could not delete agent ${path}: ${err}`, true)
return false
}
}
/** An agent's menu, as its home row and its page both show it. */
export function agentMenuItems(agent: {
path: string
canWrite: boolean
/** Never deployed: no resource yet to hold permissions or to deploy onward. */
draftOnly?: boolean
wsSpecific?: boolean
/** Undefined until loaded, when nothing is offered for deploy. */
deployUiSettings: WorkspaceDeployUISettings | undefined
onPermissions: () => void
onDeploy: () => void
onDelete: (event?: MouseEvent) => void
}): Item[] {
const deployable =
!agent.wsSpecific &&
!agent.draftOnly &&
isDeployable('resource', agent.path, agent.deployUiSettings)
return [
{
displayName: 'Permissions',
icon: Shield,
disabled: !agent.canWrite || Boolean(agent.draftOnly),
action: agent.onPermissions
},
...(deployable
? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }]
: []),
{
displayName: 'Delete',
icon: Trash,
type: 'delete' as const,
disabled: !agent.canWrite,
action: agent.onDelete
}
]
}
@@ -7,6 +7,11 @@ import { canWrite } from '$lib/utils'
import { userStore } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { onUserInput } from '$lib/userDraftEditGate'
import { DEFAULT_AGENT_MEMORY } from './agentFormFields'
import { getUsernameForNamespace } from '$lib/userNamespace'
import { random_adj } from '$lib/components/random_positive_adjetive'
import { useTriggerDraftSync, type TriggerDraftSync } from '../triggers/useTriggerDraftSync.svelte'
import { logReusableAgentUsage } from './agentTelemetry'
import {
@@ -177,10 +182,30 @@ async function writeAgentResource(
return { ok: true }
}
/** A `u/<user>/<adjective>_agent` no resource holds yet, as the path field picks one for a new
* script or flow. Gives up on availability after a few draws rather than hold the editor. */
async function freeAgentPath(workspace: string): Promise<string> {
const mint = () => `u/${getUsernameForNamespace()}/${random_adj()}_agent`
let candidate = mint()
for (let i = 0; i < 10; i++) {
const taken = await ResourceService.existsResource({ workspace, path: candidate }).catch(
() => false
)
if (!taken) break
candidate = mint()
}
return candidate
}
export interface AgentDraftOptions {
/** The `ai_agent` resource being edited. */
path: () => string | undefined
workspace: () => string | undefined
/** A missing row is a new agent to start empty, not a load failure. */
isNew?: () => boolean
/** Only the deployed agent, as a page that runs it shows it: no draft is loaded, restored or
* written, and a write from another tab does not land here. */
deployedOnly?: () => boolean
}
export interface AgentDraftHandle {
@@ -221,7 +246,8 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
const sync = useTriggerDraftSync({
itemKind: 'resource',
path: () => opts.path() ?? '',
// An empty path holds no draft handle, which is what keeps a deployed-only view off the draft.
path: () => (opts.deployedOnly?.() ? '' : (opts.path() ?? '')),
workspace: () => opts.workspace(),
drawerLoading: () => loading || refusal != null,
// `$state.snapshot` deep-reads, so the sync effects re-run when a nested field of `args`
@@ -234,6 +260,20 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
deployed: () => deployed as Record<string, any> | undefined
})
/** A new agent has no deployed value for the sync to absorb the form's settling into, so until
* the user's first input the draft cell follows the form as a seed instead: what the editor
* fills in on its own, and the name the path field shows, is not an edit to save. */
let seedNewUntilInput = $state<{ ws: string; path: string } | undefined>(undefined)
onUserInput(() => {
seedNewUntilInput = undefined
})
$effect(() => {
const target = seedNewUntilInput
if (!target || !state) return
const settled = $state.snapshot(state)
untrack(() => UserDraft.seed('resource', target.path, settled, { workspace: target.ws }))
})
function refuse(reason: string) {
loading = false
refusal = reason
@@ -260,12 +300,13 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
loadedFor = key
loading = true
refusal = undefined
seedNewUntilInput = undefined
// The user alongside the resource, as the generic resource editor loads it: a session or
// fork editor operates on a workspace that is not the one being navigated, and groups,
// folders and the admin flag are all per workspace, so the nav user would answer for the
// wrong membership in both directions.
Promise.all([
ResourceService.getResource({ workspace: ws, path, getDraft: true }),
ResourceService.getResource({ workspace: ws, path, getDraft: !opts.deployedOnly?.() }),
getUserExt(ws).catch(() => undefined)
])
// The rejection handler is `then`'s second argument rather than a trailing `catch`, so
@@ -319,18 +360,48 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
// conflict or failure for the key: a conflict is deliberately sticky (the retry
// keeps the same baseline), and nothing else mounts a resolver for `resource`
// drafts, so re-opening the agent is the only place it can be resolved.
UserDraftDbSyncer.recordRemoteSync(
{ workspace: ws, itemKind: 'resource', path },
(r as { draft_saved_at?: string }).draft_saved_at
)
if (!opts.deployedOnly?.()) {
UserDraftDbSyncer.recordRemoteSync(
{ workspace: ws, itemKind: 'resource', path },
(r as { draft_saved_at?: string }).draft_saved_at
)
}
loading = false
await sync.maybeRestore()
},
(err) => {
async (err) => {
if (loadedFor !== key) return
// Nothing is written until the first edit: the sync saves only on user input, and
// the first deploy creates the resource at whatever path the form then holds. The
// draft stays at the minted `draft_<uuid>` storage path, and the form starts on the
// free name a new script or flow gets. Named here rather than by the path field: a
// name minted after the seed below would differ from it, and the first click would
// save it.
if (opts.isNew?.() && (err as { status?: number })?.status === 404) {
const name = await freeAgentPath(ws)
if (loadedFor !== key) return
noDeployed = true
deployed = undefined
canWriteResource = true
state = {
path: name,
description: '',
// Opens on a working chat, the way a new agent is first tried. The editor says
// what memory is for, and how to turn it off, while it is on.
args: { memory: structuredClone(DEFAULT_AGENT_MEMORY) },
resource_type: 'ai_agent',
wsSpecific: false
}
loading = false
// Seeds the draft cell with the empty agent, or its first-write guard swallows the
// first edit. Not `sync.maybeRestore`: with nothing deployed to compare against, it
// takes the form for a restored draft and autosaves it before any input.
seedNewUntilInput = { ws, path }
return
}
// A failed load knows neither the resource's type nor its value, so it refuses:
// clearing `loading` alone would let the sync restore a persisted draft into a form
// that would then deploy over a resource nobody read.
if (loadedFor !== key) return
refuse(`Could not load agent ${path}: ${err}`)
}
)
@@ -14,10 +14,13 @@ export interface AgentEditorTarget {
workspace?: string
toolId?: string
/** A level of the editor that is not the form. Mutually exclusive with `toolId`. */
view?: 'evals'
view?: 'evals' | 'settings'
/** Where to re-resolve a graph's tool nodes after a deploy, when opened from a flow step. Only a
* real flow sets it: the agent editor offers no way to open a second editor from inside itself. */
host?: { flowPath: string; moduleId: string }
/** The path was minted for an agent that does not exist yet: a missing row is an empty agent to
* start from rather than a load failure. */
isNew?: boolean
}
let target = $state<AgentEditorTarget | undefined>(undefined)
@@ -0,0 +1,151 @@
<!--
@component
A saved agent's configuration, read-only: the model it calls, the instructions it is given and
the tools it can use. Laid out as the AI session's assistant settings are, and its Tools section
is that one, since both answer the same question about what an assistant can see and use.
-->
<script lang="ts">
import { Boxes, Cpu, ScrollText } from 'lucide-svelte'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
import AssistantToolsSection from '$lib/components/copilot/chat/AssistantToolsSection.svelte'
import type { ToolSummary } from '$lib/components/copilot/chat/agentContext'
import {
AGENT_BRAIN_LABELS,
summarizeAgentBrain,
type AIAgentConfig
} from '../agentResourceUtils'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
type Section = 'model' | 'instructions' | 'tools'
interface Props {
config: AIAgentConfig
/** The arguments a tool takes, where they are known. */
toolSchema?: (id: string) => any
}
let { config, toolSchema = undefined }: Props = $props()
let isOpen = $state(false)
let section = $state<Section>('model')
let toolsBusy = $state(false)
export function open(target: Section = section) {
section = target
isOpen = true
}
const SYSTEM_PROMPT_LABEL = AGENT_BRAIN_LABELS['system_prompt']
let settings = $derived(
summarizeAgentBrain(config).filter((r) => r.label !== SYSTEM_PROMPT_LABEL)
)
let systemPrompt = $derived(
typeof config.system_prompt === 'string' && config.system_prompt !== ''
? config.system_prompt
: undefined
)
let tools = $derived<ToolSummary[]>(
(Array.isArray(config.tools) ? (config.tools as AgentTool[]) : [])
.map((tool) => ({
name: toolDisplayName(tool) ?? tool?.id ?? '',
description: toolKind(tool),
parameters: { required: [], ...(toolSchema?.(tool?.id) ?? {}) }
}))
.sort((a, b) => a.name.localeCompare(b.name))
// The section keys and deduplicates its rows by name, and two MCP servers or web searches
// can share one, so a repeat is told apart by its position.
.map((tool, i, all) => {
const nth = all.slice(0, i).filter((t) => t.name === tool.name).length
return nth === 0 ? tool : { ...tool, name: `${tool.name} (${nth + 1})` }
})
)
let sections = $derived([
{ id: 'model', label: 'Model', icon: Cpu },
{ id: 'instructions', label: 'Instructions', icon: ScrollText },
{ id: 'tools', label: 'Tools', icon: Boxes, count: tools.length }
])
function toolKind(tool: AgentTool): string {
const value = tool?.value as Record<string, any> | undefined
if (value?.tool_type === 'mcp') return 'MCP server'
if (value?.tool_type === 'websearch') return 'Web search'
switch (value?.type) {
case 'script':
return `Script ${value.path ?? ''}`.trim()
case 'flow':
return `Flow ${value.path ?? ''}`.trim()
case 'rawscript':
return 'Inline script'
case 'aiagent':
return 'Agent'
default:
return 'Tool'
}
}
</script>
<Modal2
bind:isOpen
title="Agent configuration"
fixedWidth="md"
fixedHeight="lg"
closeOnOutsideClick={!toolsBusy}
closeOnEscape={!toolsBusy}
>
{#snippet headerLeft()}
<p class="pl-3 pt-1 text-xs text-secondary truncate">
What the agent runs with. Edit the agent to change it.
</p>
{/snippet}
<div class="w-full flex min-h-0 gap-4">
<div class="w-52 shrink-0 flex flex-col border-r border-border-light pr-3">
<SidebarNavigation
groups={[{ items: sections }]}
selectedId={section}
onNavigate={(id) => (section = id as Section)}
/>
</div>
<div class="grow min-w-0 flex flex-col min-h-0">
{#if section === 'model'}
<dl class="flex flex-col gap-3 overflow-y-auto pr-2">
{#each settings as row (row.label)}
<div class="flex flex-col gap-0.5">
<dt class="text-2xs text-secondary">{row.label}</dt>
<dd class="text-xs text-primary break-words">{row.value}</dd>
</div>
{:else}
<span class="text-xs text-secondary">Not configured</span>
{/each}
</dl>
{:else if section === 'instructions'}
<div class="overflow-y-auto pr-2">
{#if systemPrompt}
<p
class="text-xs text-primary whitespace-pre-wrap break-words rounded-md bg-surface-secondary p-3"
>
{systemPrompt}
</p>
{:else}
<span class="text-xs text-secondary">No system message</span>
{/if}
</div>
{/if}
<!-- Mounted throughout, as the assistant settings keep it: it owns its scrolling and
the detail page it opens. -->
<div class="{section === 'tools' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden">
<AssistantToolsSection
{tools}
providerTools={[]}
description="What the agent can call: scripts, flows, other agents, MCP servers and web search."
active={section === 'tools'}
bind:blocksClose={toolsBusy}
/>
</div>
</div>
</div>
</Modal2>
@@ -1,7 +1,8 @@
<script lang="ts">
import { getContext, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import { Info, MessageCircleOff } from 'lucide-svelte'
import { MessageCircleOff, SlidersHorizontal } from 'lucide-svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import { Alert, Button } from '$lib/components/common'
import FlowChat from '../conversations/FlowChat.svelte'
import {
@@ -11,8 +12,10 @@
agentChatPath
} from '../conversations/agentEditorChat'
import { runFlowPreview } from '../utils.svelte'
import { goto } from '$lib/navigation'
import { deepEqual } from 'fast-equals'
import type { Flow, FlowModule, InputTransform, Job, OpenFlow } from '$lib/gen'
import { JobService } from '$lib/gen'
import { emptySchema, type StateStore } from '$lib/utils'
import type { FlowInput } from '$lib/components/flows/types'
import type { FlowEditorContext, FlowInputEditorState, FlowPanelDetachContext } from '../types'
@@ -47,8 +50,6 @@
} from '../agentFormFields'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import { useAgentDraft } from '../agentDraft.svelte'
import Path from '$lib/components/Path.svelte'
import Label from '$lib/components/Label.svelte'
import { sendUserToast } from '$lib/toast'
interface Props {
@@ -61,6 +62,25 @@
onSelectTool?: (toolId: string | undefined) => void
/** Ran after a successful deploy, with the path actually written, which a rename can move. */
onSaved?: (path: string) => void | Promise<void>
/** The path was minted for a new agent, so a missing row starts an empty one. */
isNew?: boolean
/** The deployed agent to run rather than a draft to edit: the run pane leads, and the form
* beside it only shows the configuration. Fixed for the mount's lifetime. */
view?: boolean
/** Opens the agent's configuration, offered beside the model it runs with. */
onOpenConfig?: () => void
/** A view's form mode: the run form, drawn by the page. Kept out of this component because
* the flow editor reaches it, and the run form reads the navigation workspace. */
viewForm?: import('svelte').Snippet<
[
{
schema: typeof AGENT_CHAT_SCHEMA
run: (scheduledFor: string | undefined, args: Record<string, any>) => Promise<void>
loading: boolean
actions: import('svelte').Snippet | undefined
}
]
>
}
let {
@@ -69,7 +89,11 @@
enableAi = false,
toolId = undefined,
onSelectTool = undefined,
onSaved = undefined
onSaved = undefined,
isNew = false,
view = false,
onOpenConfig = undefined,
viewForm = undefined
}: Props = $props()
/** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a
@@ -78,12 +102,17 @@
* schema to the root's. */
const AGENT_ID = '__wm_agent_root'
const draft = useAgentDraft({ path: () => path, workspace: () => workspace })
const draft = useAgentDraft({
path: () => path,
workspace: () => workspace,
isNew: () => isNew,
deployedOnly: () => view
})
/** Read access only. Everything that could write is blocked, down to the draft itself: an
* autosave the server rejects would look like a save and lose the edit. Running the agent,
* its history and its evals stay open, none of them being a write to the resource. */
let readOnly = $derived(!draft.canWrite)
let readOnly = $derived(view || !draft.canWrite)
const flowStore = $state({
val: {
@@ -329,6 +358,16 @@
inputs?: Record<string, any>
): Promise<string | undefined> {
if (!agentModule) return undefined
// A view runs the deployed agent, which anyone who can read it may do; the editor previews
// the draft. Both run as the caller.
if (view && workspace) {
return await JobService.runAgent({
workspace,
path,
memoryId: conversationId,
requestBody: { ...(inputs ?? {}), user_message: userMessage }
})
}
return await runFlowPreview(
{ ...(inputs ?? {}), user_message: userMessage },
agentChatFlow($state.snapshot(agentModule) as FlowModule),
@@ -340,6 +379,21 @@
)
}
let runLoading = $state(false)
/** One run of the deployed agent from the view's form, outside any conversation, then onto its
* run page. */
async function runOnce(_scheduledFor: string | undefined, args: Record<string, any>) {
if (!workspace) return
runLoading = true
try {
const id = await JobService.runAgent({ workspace, path, requestBody: args })
await goto(`/run/${id}?workspace=${workspace}`)
} finally {
runLoading = false
}
}
// What the composer reads for its model button and paperclip.
let chatModules = $derived(agentModule ? agentChatFlow(agentModule).value.modules : undefined)
let chatGap = $derived(agentChatGap(agentValue?.input_transforms))
@@ -352,6 +406,11 @@
}
}
function turnOffMemory() {
if (!agentValue) return
agentValue.input_transforms.memory = { type: 'static', value: { kind: 'off' } }
}
function turnOnStreaming() {
if (!agentValue) return
agentValue.input_transforms.streaming = { type: 'static', value: true }
@@ -387,12 +446,18 @@
}
/** The path field's own verdict (a taken path, an invalid name), which the server would otherwise
* only report after the request. */
let pathError = $state('')
* only report after the request. The field lives with whoever shows it, which reports here. */
let pathErrorValue = $state('')
export function pathError() {
return pathErrorValue
}
export function setPathError(error: string | undefined) {
pathErrorValue = error ?? ''
}
export function deploy(): Promise<boolean> {
if (pathError) {
sendUserToast(`Cannot deploy the agent: ${pathError}`, true)
if (pathErrorValue) {
sendUserToast(`Cannot deploy the agent: ${pathErrorValue}`, true)
return Promise.resolve(false)
}
return draft.deploy().then(async (written) => {
@@ -404,6 +469,10 @@
export function draftHandle() {
return draft
}
/** The arguments a tool takes, as this editor inferred them; undefined until it has. */
export function toolSchema(id: string): any {
return flowStateStore.val[id]?.schema
}
/** The switch between the form and the chat, which the dialog's header holds. `mode` is
* undefined until the agent loads and picks the first one. */
export function testPaneHandle() {
@@ -418,6 +487,46 @@
}
</script>
<!-- Beside the model in the chat's composer, as the AI session's assistant settings sit. -->
{#snippet configButton()}
<Tooltip small placement="top">
<Button
unifiedSize="2xs"
variant="subtle"
iconOnly
startIcon={{ icon: SlidersHorizontal }}
aria-label="Agent configuration"
onClick={() => onOpenConfig?.()}
/>
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">Agent configuration</p>
<p class="mt-1">The model, instructions and tools this agent runs with.</p>
</div>
{/snippet}
</Tooltip>
{/snippet}
<!-- In the form's Run row, where a schedulable form keeps its Advanced options. -->
{#snippet configurationButton()}
<Tooltip small placement="top">
<Button
unifiedSize="md"
variant="default"
iconOnly
startIcon={{ icon: SlidersHorizontal }}
aria-label="Agent configuration"
onClick={() => onOpenConfig?.()}
/>
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">Agent configuration</p>
<p class="mt-1">The model, instructions and tools this agent runs with.</p>
</div>
{/snippet}
</Tooltip>
{/snippet}
{#if draft.refusal}
<div class="h-full flex items-center justify-center px-8">
<Alert type="error" size="sm" title={draft.refusal} class="max-w-lg">
@@ -431,155 +540,170 @@
<!-- Named and positioned so the tool picker's popover can portal here: the `#flow-editor` it
otherwise targets is behind this dialog, and does not exist at all on the resources page. -->
<div id="agent-editor" class="relative h-full min-h-0">
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
answer want opposite splits, and only the reader knows which they are on. -->
<Splitpanes class="h-full">
<Pane size={55} minSize={30}>
<div class="h-full min-h-0 overflow-auto">
<div class="px-4 pt-4">
<Label label="Path">
<Path
bind:path={
() => draft.state?.path,
(v) => {
if (draft.state && v !== undefined) draft.state.path = v
}
}
bind:error={pathError}
initialPath={path}
namePlaceholder="agent"
kind="resource"
workspaceOverride={workspace}
autofocus={false}
disabled={readOnly}
/>
</Label>
</div>
<PropPickerWrapper
pickableProperties={stepPropPicker?.pickableProperties}
noPadding
sidePane
>
<AiAgentStepInputs
class="px-4 pb-8"
{schema}
filter={brainFilter}
previousModuleId={undefined}
pickableProperties={stepPropPicker?.pickableProperties}
extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'}
{enableAi}
{workspace}
staticOnly
visibilityKey={`agent:${path}`}
{tools}
{readOnly}
onSelectTool={(id) => onSelectTool?.(id)}
onAddTool={readOnly ? undefined : addTool}
onDeleteTool={readOnly ? undefined : deleteTool}
toolPickerPortal="#agent-editor"
bind:args={
() => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>,
(v) => agentValue && (agentValue.input_transforms = v)
}
/>
</PropPickerWrapper>
</div>
</Pane>
<Pane size={45} minSize={20}>
<div class="h-full min-h-0 flex flex-col">
<!-- Laid out as the script editor's preview column is: what a run takes above what it
{#if view}
<!-- A view only runs the agent: the page beside it summarizes the configuration. -->
{@render runPane()}
{:else}
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
answer want opposite splits, and only the reader knows which they are on. -->
<Splitpanes class="h-full">
<Pane size={55} minSize={30}>{@render configPane()}</Pane>
<Pane size={45} minSize={20}>{@render runPane()}</Pane>
</Splitpanes>
{/if}
</div>
{#snippet configPane()}
<div class="h-full min-h-0 overflow-auto">
<PropPickerWrapper pickableProperties={stepPropPicker?.pickableProperties} noPadding sidePane>
<AiAgentStepInputs
class="px-4 pt-4 pb-8"
{schema}
filter={brainFilter}
previousModuleId={undefined}
pickableProperties={stepPropPicker?.pickableProperties}
extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'}
{enableAi}
{workspace}
staticOnly
visibilityKey={`agent:${path}`}
{tools}
{readOnly}
onSelectTool={(id) => onSelectTool?.(id)}
onAddTool={readOnly ? undefined : addTool}
onDeleteTool={readOnly ? undefined : deleteTool}
toolPickerPortal="#agent-editor"
bind:args={
() => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>,
(v) => agentValue && (agentValue.input_transforms = v)
}
/>
</PropPickerWrapper>
</div>
{/snippet}
{#snippet runPane()}
<div class="h-full min-h-0 flex flex-col">
<!-- Laid out as the script editor's preview column is: what a run takes above what it
produced, both alongside what is being edited. -->
<div class="flex-1 min-h-0 {testMode === 'chat' ? 'hidden' : ''}">
<Splitpanes horizontal class="h-full">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
/>
</div>
</Pane>
<Pane size={60} minSize={20}>
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</Pane>
</Splitpanes>
{#if view}
<!-- A run of the deployed agent is a run like any other item's, so it opens on its
own page rather than as a test result beside the form. -->
<!-- Centered at the width the chat's column keeps, so switching modes moves nothing. -->
<div class="flex-1 min-h-0 overflow-auto p-4 {testMode === 'chat' ? 'hidden' : ''}">
<div class="max-w-3xl mx-auto">
{@render viewForm?.({
schema: AGENT_CHAT_SCHEMA,
run: runOnce,
loading: runLoading,
actions: onOpenConfig ? configurationButton : undefined
})}
</div>
{#if chatMounted}
<div class={testMode === 'chat' ? 'flex flex-col flex-1 min-h-0' : 'hidden'}>
{#if chatGap?.memory}
<div
class="flex-1 flex flex-col items-center justify-center gap-2 px-8 text-center"
</div>
{:else}
<div class="flex-1 min-h-0 flex flex-col {testMode === 'chat' ? 'hidden' : ''}">
<!-- An agent reused as a step has no use for memory unless its flow gives it a
conversation, and the form is where such an agent is tried. -->
{#if !chatGap?.memory && !readOnly}
<Alert type="info" size="xs" title="Managed memory is on" class="m-2 shrink-0">
It keeps the conversation between runs, which is useful if you chat with this agent,
use it in a flow in chat mode, or pass it a memory id. Otherwise, you can turn it off.
<div class="flex mt-2">
<Button unifiedSize="2xs" variant="default" onClick={turnOffMemory}>Turn off</Button
>
<MessageCircleOff size={48} class="text-tertiary opacity-50 mb-2" />
<p class="text-sm font-semibold text-emphasis">Chat needs managed memory</p>
<p class="text-xs text-secondary max-w-xs">
{chatGap.memoryCanTurnOn
? 'Without it, every message would be answered without the ones before it.'
: 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'}
</p>
{#if chatGap.memoryCanTurnOn && !readOnly}
<Button
unifiedSize="sm"
variant="default"
btnClasses="bg-surface mt-2"
onClick={turnOnMemory}
>
Turn on managed memory
</Button>
{/if}
</div>
{:else if chatGap?.noStream}
<div
class="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b text-2xs text-secondary"
>
<Info size={12} class="shrink-0" />
<span class="flex-1">
{chatGap.noStream === 'image'
? 'Image answers do not stream: each one shows once its run ends.'
: 'Streaming is off: each answer shows once its run ends.'}
</span>
{#if chatGap.noStream === 'off' && !readOnly}
<Button unifiedSize="2xs" variant="subtle" onClick={turnOnStreaming}>
Turn on
</Button>
{/if}
</div>
{/if}
<!-- Hidden rather than unmounted while memory is off: switching it off mid-turn must
not end the chat following that turn. Test chats, since what runs is the agent as
edited. -->
<div class={chatGap?.memory ? 'hidden' : 'flex flex-col flex-1 min-h-0'}>
<FlowChat
onRunFlow={runChatTurn}
path={chatPath}
conversationKind="test"
subject="agent"
frame="none"
inputSchema={AGENT_CHAT_SCHEMA}
flowModules={chatModules}
</div>
</Alert>
{/if}
<Splitpanes horizontal class="flex-1 min-h-0">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
/>
</div>
</div>
{/if}
</Pane>
<Pane size={60} minSize={20}>
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</Pane>
</Splitpanes>
</div>
</Pane>
</Splitpanes>
</div>
{/if}
{#if chatMounted}
<div class={testMode === 'chat' ? 'flex flex-col flex-1 min-h-0' : 'hidden'}>
{#if chatGap?.memory}
<div class="flex-1 flex flex-col items-center justify-center gap-2 px-8 text-center">
<MessageCircleOff size={48} class="text-tertiary opacity-50 mb-2" />
<p class="text-sm font-semibold text-emphasis">Chat needs managed memory</p>
<p class="text-xs text-secondary max-w-xs">
{chatGap.memoryCanTurnOn
? 'Without it, every message would be answered without the ones before it.'
: 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'}
</p>
{#if chatGap.memoryCanTurnOn && !readOnly}
<Button
unifiedSize="sm"
variant="default"
btnClasses="bg-surface mt-2"
onClick={turnOnMemory}
>
Turn on managed memory
</Button>
{/if}
</div>
{:else if chatGap?.noStream}
<Alert
type="info"
size="xs"
title={chatGap.noStream === 'image'
? 'Image answers do not stream'
: 'Streaming is off'}
class="m-2 shrink-0"
>
Each answer shows once its run ends.
{#if chatGap.noStream === 'off' && !readOnly}
<div class="flex mt-2">
<Button unifiedSize="2xs" variant="default" onClick={turnOnStreaming}
>Turn on</Button
>
</div>
{/if}
</Alert>
{/if}
<!-- Hidden rather than unmounted while memory is off: switching it off mid-turn must
not end the chat following that turn. Test chats, since what runs is the agent as
edited. -->
<div class={chatGap?.memory ? 'hidden' : 'flex flex-col flex-1 min-h-0'}>
<FlowChat
onRunFlow={runChatTurn}
path={chatPath}
conversationKind={view ? 'deployed' : 'test'}
subject="agent"
frame="none"
inputSchema={AGENT_CHAT_SCHEMA}
flowModules={chatModules}
composerSettings={onOpenConfig ? configButton : undefined}
/>
</div>
</div>
{/if}
</div>
{/snippet}
<!-- A tool is a whole step editor, so it gets a surface of its own rather than a level of the
dialog: the agent stays visible behind it, along with the banner and Deploy that its edits
@@ -1,5 +1,13 @@
<script lang="ts">
import { FlaskConical, FormInput, History, MessageSquare, Save } from 'lucide-svelte'
import {
ChevronRight,
FlaskConical,
Settings,
FormInput,
History,
MessageSquare,
Save
} from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { onDestroy, untrack } from 'svelte'
@@ -28,6 +36,8 @@
import { publishLinkedAgentTools } from '../flowState'
import { linkedModulesForAgent, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
import AgentEditorHost from './AgentEditorHost.svelte'
import AgentSettings from './AgentSettings.svelte'
import AgentEvalsModal from './AgentEvalsModal.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -43,9 +53,23 @@
/** A deploy moved the agent from `from` to `to`. What names the old path belongs to the surface
* that opened the editor: a flow's own steps, a page's URL. */
onRenamed?: (from: string, to: string) => void
/** `page` fills its container with the editor's header and body instead of opening a dialog. */
layout?: 'modal' | 'page'
/** Where closing leads, for a layout that has no dialog to dismiss. `deployed` is false for an
* agent that exists only as a draft, which has no page of its own to return to. */
onClose?: (deployed: boolean) => void
/** Ran after every successful deploy, with the path written. */
onDeployed?: (path: string) => void
}
let { enableAi = false, owns, onRenamed = undefined }: Props = $props()
let {
enableAi = false,
owns,
onRenamed = undefined,
layout = 'modal',
onClose = undefined,
onDeployed = undefined
}: Props = $props()
// Every target names the surface that opened it, and only a flow step or a resource row can:
// an agent used as a tool of the agent being edited stays part of it, with no way in this editor
@@ -68,6 +92,8 @@
let ws = $derived(target?.workspace ?? $operatingWorkspace)
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let versionDrawer: Drawer | undefined = $state(undefined)
let settingsDrawer: Drawer | undefined = $state(undefined)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
let saving = $state(false)
// Counted per agent, so a deploy that leaves the editor on the same agent still refetches the
@@ -100,8 +126,19 @@
// the dialog carries only the refusal the host renders.
let refused = $derived(draft?.refusal != null)
let inEvals = $derived(target?.view === 'evals' && !refused)
// A level of its own in the dialog; the page layout opens the same settings in a drawer instead.
let inSettings = $derived(target?.view === 'settings' && !refused)
let readOnly = $derived(draft ? !draft.canWrite : false)
let draftOnly = $derived(draft?.noDeployed ?? false)
// Evals run against the deployed agent, and a draft-only one has none: the backend's
// `require_agent` would reject every run. Evaluating is authoring, so it is for those who can
// edit the agent. Unknown until the load answers, and warming the pane loads it.
let canEvaluate = $derived(
draft != undefined && !draft.loading && !draftOnly && !readOnly && !refused
)
// A never-deployed agent is stored at a minted `draft_<uuid>` path, so it is named by the path
// its first deploy will create, as the home list names it.
let shownPath = $derived((draftOnly && draft?.state?.path) || target?.path)
// Where the evals pane is within itself, so its levels extend this dialog's trail rather than
// opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on
@@ -116,8 +153,8 @@
const AGENT_DESCRIPTION = 'Changes here update the saved agent, and every flow that links to it.'
let root = $derived<ModalTrailSegment>({
label: target?.path ?? 'Agent',
onclick: inEvals ? () => showAgentEditorView(undefined) : undefined
label: shownPath ?? 'Agent',
onclick: inEvals || inSettings ? () => showAgentEditorView(undefined) : undefined
})
let trail = $derived<ModalTrailSegment[]>(
inEvals
@@ -126,11 +163,24 @@
{ label: 'Evals', onclick: evalsLocation ? evalsLocation.back : undefined },
...(evalsLocation ? [{ label: evalsLocation.label }] : [])
]
: [root]
: inSettings
? [root, { label: 'Settings' }]
: [root]
)
// The root's alone: below it the header's second line is the way back, and what a level is for
// belongs to that level rather than to the dialog's own name.
let description = $derived(inEvals || refused ? undefined : AGENT_DESCRIPTION)
let description = $derived(inEvals || inSettings || refused ? undefined : AGENT_DESCRIPTION)
// The dialog's levels are its pages; a page has none, so evals open over it as a dialog.
function openEvals() {
if (layout === 'modal') showAgentEditorView('evals')
else evalsModal?.openModal()
}
function openSettings() {
if (layout === 'modal') showAgentEditorView('settings')
else settingsDrawer?.openDrawer()
}
/** The unsaved edits, in the shape the server builds from a deployed config
* (`ai_evals/run.rs` `config_to_draft`), so a draft run's hash can be recognised as equal to
@@ -147,8 +197,11 @@
* the URL keeps claiming the agent is open and a refresh reopens it. Anchored to that page, so
* this is a no-op when the editor was opened from a flow. */
function close() {
// Unknown until the load answers, and a detail page that does not exist is the worse guess.
const deployed = draft != undefined && !draft.loading && !draftOnly && !refused
closeAgentEditor()
void clearPageDrawerAnchor(RESOURCES_PATH)
if (layout === 'modal') void clearPageDrawerAnchor(RESOURCES_PATH)
onClose?.(deployed)
}
// The target is module-global and outlives this component: navigating away takes the mount that
@@ -218,7 +271,9 @@
const at = deployingFor ?? currentWriteTarget()
// Before the rename is announced: it finds the steps to refresh under the old path.
const reconciled = reconcile(at, savedPath)
if (at && savedPath !== at.path) {
// A new agent's first deploy is announced like a rename even at the same path: the surface
// has anchored nothing for it yet, and reopening drops `isNew` now that it exists.
if (at && (savedPath !== at.path || target?.isNew)) {
onRenamed?.(at.path, savedPath)
// The dialog is keyed on the path, so this reloads it on the renamed agent. Only while it
// still shows the one deployed: it can be closed or pointed elsewhere mid-request.
@@ -227,6 +282,7 @@
}
}
await reconciled
onDeployed?.(savedPath)
}
</script>
@@ -238,160 +294,227 @@
`paginated` throughout: the body is one strip of pages (the agent form, then evals), so
the header keeps its height and only the pages move. -->
<Modal
bind:open={() => true, (open) => !open && close()}
kind="X"
fillHeight
enterConfirms={false}
paginated
title={target.path}
{trail}
{description}
class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]"
>
{#snippet titleBadge()}
<!-- Against the agent's own name wherever it appears, as the linked-agent card in the
step panel has it. -->
{#if version != undefined && !refused}
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
v{version}
</Badge>
{/if}
{/snippet}
{#snippet levelBadge()}
<!-- Marks evals, not the agent, so it sits against that level's own name. Dropped a
level deeper, where it would read as marking the run rather than the feature it
belongs to. -->
{#if inEvals && !evalsLocation}
<Badge color="blue" small class="shrink-0 !py-0 leading-4">Beta</Badge>
{/if}
{/snippet}
<!-- Evals carries none of the editor's actions: nothing there edits the agent, and the run
dialog states for itself whether a run is against the deployed version or the edits. -->
{#snippet settings()}
<div class="flex flex-row items-center gap-2 shrink-0">
{#if !inEvals && !refused}
<!-- Switches the editor's right-hand pane, and is drawn only once the agent has
loaded and the pane has picked its first mode. -->
{#if testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="sm"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="sm"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{#if readOnly}
<Badge
color="gray"
class="shrink-0"
title="You do not have write access to this agent"
>
Read only
</Badge>
{/if}
<!-- Evals run against the deployed agent, and a draft-only one has none: the
backend's `require_agent` would reject every run. -->
{#if !draftOnly}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: FlaskConical }}
title="Run this agent against a dataset of cases"
on:click={() => showAgentEditorView('evals')}
>
Evals
</Button>
{/if}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: History }}
iconOnly
title="Version history"
on:click={() => versionDrawer?.openDrawer()}
/>
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Save }}
loading={saving}
disabled={readOnly}
title={readOnly ? 'You do not have write access to this agent' : undefined}
on:click={onDeploy}
>
Deploy
</Button>
{/if}
</div>
{/snippet}
{#if layout === 'modal'}
<Modal
bind:open={() => true, (open) => !open && close()}
kind="X"
fillHeight
enterConfirms={false}
paginated
title={shownPath ?? target.path}
{trail}
{description}
{titleBadge}
{levelBadge}
{settings}
class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]"
>
{@render body()}
</Modal>
{:else}
<div class="h-full min-h-0 flex flex-col">
<!-- Full-bleed, as a banner is everywhere else: the dialog's own horizontal padding is
cancelled so it spans the body. -->
<div class="-mx-4 sm:-mx-6 shrink-0 {inEvals ? 'hidden' : ''}">
<LocalDraftBanner
show={draft?.sync.hasDraft ?? false}
reserveSpace={draft?.sync.hasBaseline ?? false}
getDeployed={() => draft?.deployed}
getCurrent={() => draft?.state}
onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')}
title="Deployed <> Unsaved agent changes"
/>
<div class="flex items-center gap-2 px-4 py-2 border-b shrink-0">
<div class="min-w-0 flex flex-col">
<div class="flex items-center gap-1 min-w-0">
{#each trail as segment, i (i)}
{#if i > 0}
<ChevronRight size={14} class="text-tertiary shrink-0" />
{/if}
{#if segment.onclick}
<Button variant="subtle" unifiedSize="sm" onClick={segment.onclick}>
{segment.label}
</Button>
{:else}
<span class="text-sm font-semibold text-emphasis truncate">{segment.label}</span>
{/if}
{#if i === 0}
{@render titleBadge()}
{/if}
{/each}
{@render levelBadge()}
</div>
{#if description}
<span class="text-2xs text-tertiary truncate">{description}</span>
{/if}
</div>
<div class="grow"></div>
{@render settings()}
</div>
<div class="flex-1 min-h-0 px-4 sm:px-6">
{@render body()}
</div>
<!-- Two pages of one strip, so opening evals slides in from the right the way its own
levels do. No `onNavigate`: the arrow keys belong to whichever pane is on screen,
and evals answers them for its own levels. -->
<PagedContent
class="flex-1 min-h-0"
current={inEvals ? 'evals' : 'agent'}
pages={[
{ key: 'agent', content: agentPage },
{ key: 'evals', content: evalsPage }
]}
/>
</div>
</Modal>
{/if}
{/key}
{#snippet titleBadge()}
<!-- Against the agent's own name wherever it appears, as the linked-agent card in the
step panel has it. -->
{#if version != undefined && !refused}
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
v{version}
</Badge>
{/if}
{/snippet}
{#snippet levelBadge()}
<!-- Marks evals, not the agent, so it sits against that level's own name. Dropped a
level deeper, where it would read as marking the run rather than the feature it
belongs to. -->
{#if inEvals && !evalsLocation}
<Badge color="blue" small class="shrink-0 !py-0 leading-4">Beta</Badge>
{/if}
{/snippet}
<!-- Evals carries none of the editor's actions: nothing there edits the agent, and the run
dialog states for itself whether a run is against the deployed version or the edits. -->
{#snippet settings()}
<div class="flex flex-row items-center gap-2 shrink-0">
{#if !inEvals && !refused}
<!-- Switches the editor's right-hand pane, and is drawn only once the agent has
loaded and the pane has picked its first mode. -->
{#if testPane?.mode && !inSettings}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="sm"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="sm"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{#if readOnly}
<Badge color="gray" class="shrink-0" title="You do not have write access to this agent">
Read only
</Badge>
{/if}
{#if !inSettings}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Settings }}
iconOnly
title="Settings"
on:click={openSettings}
/>
{/if}
{#if canEvaluate}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: FlaskConical }}
title="Run this agent against a dataset of cases"
on:click={openEvals}
>
Evals
</Button>
{/if}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: History }}
iconOnly
title="Version history"
on:click={() => versionDrawer?.openDrawer()}
/>
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Save }}
loading={saving}
disabled={readOnly}
title={readOnly ? 'You do not have write access to this agent' : undefined}
on:click={onDeploy}
>
Deploy
</Button>
{/if}
</div>
{/snippet}
{#snippet body()}
<div class="h-full min-h-0 flex flex-col">
<!-- Full-bleed, as a banner is everywhere else: the body's own horizontal padding is
cancelled so it spans it. -->
<div class="-mx-4 sm:-mx-6 shrink-0 {inEvals ? 'hidden' : ''}">
<LocalDraftBanner
show={draft?.sync.hasDraft ?? false}
reserveSpace={draft?.sync.hasBaseline ?? false}
getDeployed={() => draft?.deployed}
getCurrent={() => draft?.state}
onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')}
title="Deployed <> Unsaved agent changes"
/>
</div>
<!-- One strip of pages, so a level slides in from the right the way evals' own levels do.
No `onNavigate`: the arrow keys belong to whichever pane is on screen, and evals answers
them for its own levels. Warmed, as those levels are: a page built on its first visit
lands inside the transition and arrives empty. The page layout has no levels: it opens
settings in a drawer and evals in a dialog, so warming them there would build them twice. -->
<PagedContent
warm
class="flex-1 min-h-0"
current={inEvals ? 'evals' : inSettings ? 'settings' : 'agent'}
pages={[
{ key: 'agent', content: agentPage },
...(layout === 'modal' ? [{ key: 'settings', content: settingsPage }] : []),
...(layout === 'modal' && canEvaluate ? [{ key: 'evals', content: evalsPage }] : [])
]}
/>
</div>
{/snippet}
<!-- Mounted under the evals level too: it holds the draft the header's banner and Deploy act on,
and the config a draft run is offered on. -->
and the config a draft run is offered on. The target is read optionally: a deploy that
navigates away clears it while these are still rendered. -->
{#snippet agentPage()}
<AgentEditorHost
bind:this={host}
path={target.path}
path={target?.path ?? ''}
workspace={ws}
{enableAi}
toolId={target.toolId}
toolId={target?.toolId}
onSelectTool={(id) => showAgentEditorTool(id)}
{onSaved}
isNew={target?.isNew}
/>
{/snippet}
{#snippet settingsFields()}
{#if draft}
<AgentSettings
{draft}
path={target?.path ?? ''}
workspace={ws}
bind:error={() => host?.pathError(), (error) => host?.setPathError(error)}
/>
{/if}
{/snippet}
{#snippet settingsPage()}
<div class="max-w-2xl py-6">{@render settingsFields()}</div>
{/snippet}
{#snippet evalsPage()}
<EvalsPane
agentPath={target.path}
agentPath={target?.path ?? ''}
opWorkspace={ws}
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
bind:location={evalsLocation}
@@ -399,6 +522,21 @@
/>
{/snippet}
{#if layout === 'page' && ws}
<AgentEvalsModal
bind:this={evalsModal}
agentPath={target?.path ?? ''}
workspace={ws}
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
/>
{/if}
<Drawer bind:this={settingsDrawer} size="600px">
<DrawerContent title="Settings" on:close={() => settingsDrawer?.closeDrawer()}>
{@render settingsFields()}
</DrawerContent>
</Drawer>
<Drawer bind:this={versionDrawer} size="1200px">
<DrawerContent title="Version history" on:close={() => versionDrawer?.closeDrawer()} noPadding>
<ResourceVersionHistory
@@ -0,0 +1,49 @@
<script lang="ts">
import { Badge } from '$lib/components/common'
import Modal from '$lib/components/common/modal/Modal.svelte'
import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte'
import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils'
import type { AgentDraft } from '$lib/gen'
/** An agent's evals in a dialog of their own, over the page that opened them. */
interface Props {
agentPath: string
workspace: string
/** The unsaved edits, offered to a run as an alternative to the deployed version. */
editedConfig?: () => AgentDraft
}
let { agentPath, workspace, editedConfig = undefined }: Props = $props()
let open = $state(false)
// Where the evals pane is within itself, so its levels extend the dialog's trail. Cleared on the
// way in: the pane reports a level once it is on one, and never that it is back at its root.
let location = $state<EvalsLocation | undefined>(undefined)
export function openModal() {
location = undefined
open = true
}
</script>
{#if open}
<Modal
bind:open
kind="X"
fillHeight
enterConfirms={false}
title="Evals"
trail={[
{ label: 'Evals', onclick: location ? location.back : undefined },
...(location ? [{ label: location.label }] : [])
]}
class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]"
>
{#snippet titleBadge()}
{#if !location}
<Badge color="blue" small class="shrink-0 !py-0 leading-4">Beta</Badge>
{/if}
{/snippet}
<EvalsPane {agentPath} opWorkspace={workspace} {editedConfig} bind:location active={open} />
</Modal>
{/if}
@@ -8,6 +8,8 @@
import ResourcePathHint from '$lib/components/ResourcePathHint.svelte'
import { ResourceService, type InputTransform, type Resource } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { userStore } from '$lib/stores'
import { canWrite } from '$lib/utils'
import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte'
import {
AGENT_BRAIN_KEYS,
@@ -116,6 +118,8 @@
fromDraft: boolean
providerPath?: string
providerOk: boolean
/** The agent's own sharing, to tell whether this user may edit it. */
extraPerms?: Record<string, boolean>
/** The link cannot be read. `missing` (404): nothing exists at the path, the agent having been
* renamed or deleted. `forbidden` (401/403): it exists and this user is refused it, a folder
* they cannot read included, which says nothing about whether a run of the flow can read it.
@@ -197,7 +201,8 @@
tools,
fromDraft: draft != undefined,
providerPath,
providerOk
providerOk,
extraPerms: (response.extra_perms ?? {}) as Record<string, boolean>
}
}
)
@@ -228,6 +233,11 @@
let providerPath = $derived(linkedInfo?.providerPath)
let providerOk = $derived(linkedInfo?.providerOk ?? true)
let unavailable = $derived(linkedInfo?.unavailable ?? false)
let canEditAgent = $derived(
!!agent &&
!$userStore?.operator &&
canWrite(agent, linkedInfo?.extraPerms ?? {}, $userStore ?? undefined)
)
// The hint flips on the first keystroke in the agent editor, so the badge does not wait for the
// debounced autosave and the refetch behind it; the fetched answer covers a draft written
// elsewhere, which no editor here has published an opinion about.
@@ -678,10 +688,19 @@
</div>
{:else 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 type="warning" size="xs" title="Model provider not accessible">
<!-- Each branch holds the whole sentence: Svelte trims whitespace at a block's edges,
which is how the path once lost the spaces around it. -->
{#if providerPath}
You don't have access to <span class="font-medium">{providerPath}</span>.
{:else}
You don't have access to this agent's model provider.
{/if}
{#if canEditAgent && !fromAgentEditor}
Edit the agent to use another, unlink it, or ask for access.
{:else}
Unlink it or ask for access.
{/if}
</Alert>
</div>
{/if}
@@ -0,0 +1,79 @@
<script lang="ts">
import { resource } from 'runed'
import { Badge } from '$lib/components/common'
import Label from '$lib/components/Label.svelte'
import LabelsInput from '$lib/components/LabelsInput.svelte'
import Path from '$lib/components/Path.svelte'
import ResourceDescriptionField from '$lib/components/ResourceDescriptionField.svelte'
import ResourcePathHint from '$lib/components/ResourcePathHint.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { WorkspaceService } from '$lib/gen'
import type { AgentDraftHandle } from '../agentDraft.svelte'
/** An agent's own settings, laid out as the top of the resource editor: what it is saved as
* rather than what it does. */
interface Props {
draft: AgentDraftHandle
/** The path the editor opened, which a first deploy of a new agent replaces. */
path: string
workspace: string | undefined
/** Why the path cannot be deployed to, if it cannot. */
error?: string
}
let { draft, path, workspace, error = $bindable() }: Props = $props()
let readOnly = $derived(!draft.canWrite)
// Only a workspace that deploys somewhere has anything to keep an agent out of.
const deployTo = resource(
() => workspace,
async (ws) =>
ws ? (await WorkspaceService.getDeployTo({ workspace: ws })).deploy_to : undefined
)
</script>
{#if draft.state}
<div class="flex flex-col gap-6">
<Label label="Path">
<ResourcePathHint />
<Path
bind:path={
() => draft.state?.path,
(v) => {
if (draft.state && v !== undefined) draft.state.path = v
}
}
bind:error
initialPath={draft.noDeployed ? '' : path}
checkInitialPathExistence={draft.noDeployed}
namePlaceholder="agent"
kind="resource"
workspaceOverride={workspace}
autofocus={false}
disabled={readOnly}
/>
</Label>
{#if readOnly}
{#if draft.state.labels?.length}
<div class="-mt-4 inline-flex items-center gap-1">
{#each draft.state.labels as label (label)}
<Badge color="blue" small>{label}</Badge>
{/each}
</div>
{/if}
{:else}
<LabelsInput bind:labels={draft.state.labels} {workspace} class="-mt-4" />
{/if}
{#if deployTo.current}
<Label label="Workspace specific" tooltip="Keeps this agent out of deploys to prod/staging.">
<Toggle bind:checked={draft.state.wsSpecific} disabled={readOnly} />
</Label>
{/if}
<ResourceDescriptionField
bind:description={draft.state.description}
label="Description"
placeholder="Describe what this agent does"
canWrite={!readOnly}
/>
</div>
{/if}
@@ -61,6 +61,8 @@
/** What a message runs, as the chat names it. An agent has no deployed chats, so its
* sidebar offers no filter between those and the test ones. */
subject?: 'flow' | 'agent'
/** The host's own controls, after the model's in the composer's footer. */
composerSettings?: import('svelte').Snippet
}
let {
@@ -75,7 +77,8 @@
wideLayout = false,
frame = 'top',
conversationKind = 'deployed',
subject = 'flow'
subject = 'flow',
composerSettings = undefined
}: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
@@ -239,6 +242,7 @@
{wideLayout}
{conversationKind}
{subject}
extraSettings={composerSettings}
/>
</div>
{/if}
@@ -55,6 +55,8 @@
conversationKind?: 'test' | 'deployed'
/** What a message runs, as the composer names it. */
subject?: 'flow' | 'agent'
/** The host's own controls, after the model's in the composer's footer. */
extraSettings?: import('svelte').Snippet
}
let {
@@ -71,7 +73,8 @@
description = undefined,
wideLayout = false,
conversationKind = 'deployed',
subject = 'flow'
subject = 'flow',
extraSettings = undefined
}: Props = $props()
const operatingUser = useOperatingUser()
@@ -301,6 +304,7 @@
{workspace}
/>
{/if}
{@render extraSettings?.()}
{/snippet}
<!-- The transcript scroller fills its flex row, which needs a height to resolve
@@ -325,7 +329,7 @@
hideModeSelector
{wideLayout}
{emptyHint}
footerSettings={modalSchema || showModelButton ? footerSettings : undefined}
footerSettings={modalSchema || showModelButton || extraSettings ? footerSettings : undefined}
placeholder="Send a message to run the {subject}"
disabled={deploymentInProgress || !!modelGap || !!wrongKindReason}
disabledMessage={deploymentInProgress
@@ -8,6 +8,7 @@
import Tab from '$lib/components/common/tabs/Tab.svelte'
import {
Plus,
Bot,
Code2,
LayoutDashboard,
ChevronDown,
@@ -139,6 +140,21 @@
}
] as Option[])
: []),
{
key: 'agent',
label: 'AI agent',
icon: Bot,
accent: 'purple',
tagline: 'Chat with it, or reuse it in flows',
description:
'Pick a model, write its instructions and give it tools: scripts, flows, MCP servers or other agents. Chat with it from its own page, and add the same agent to any flow as a step.',
bullets: [
'Chat that remembers the conversation',
'Reusable as a step in any flow',
'Tools from scripts, flows and MCP'
],
onSelect: () => goto(`${base}/agents/add`)
},
...(HOME_SHOW_CREATE_FLOW
? ([
{
+16 -2
View File
@@ -2,6 +2,7 @@
import AppRow from '../common/table/AppRow.svelte'
import FlowRow from '../common/table/FlowRow.svelte'
import RawAppRow from '../common/table/RawAppRow.svelte'
import AgentRow from '../common/table/AgentRow.svelte'
import ScriptRow from '../common/table/ScriptRow.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { Alert } from '$lib/components/common'
@@ -44,9 +45,10 @@
// A raw app the listing returns is an `app` row carrying `raw_app`, and is
// selectable like any other app. The separate `raw_app` type is the legacy
// listing shape, which renders through RawAppRow — no selection control there,
// so it must not enter the selection either.
// so it must not enter the selection either. An agent is a resource underneath,
// which none of the bulk actions handle.
let bulkItem = $derived(
homeSelection?.available && item.type !== 'raw_app'
homeSelection?.available && item.type !== 'raw_app' && item.type !== 'agent'
? toBulkItem(item, $userStore, $workspaceStore)
: undefined
)
@@ -132,6 +134,18 @@
bind:menuOpen
{keyboardSelected}
/>
{:else if item.type == 'agent'}
<AgentRow
bind:deleteConfirmedCallback
marked={item.marked}
on:change={() => dispatch('reload')}
agent={item}
{shareModal}
{deploymentDrawer}
{depth}
bind:menuOpen
{keyboardSelected}
/>
{/if}
{#if menuOpen}
+172 -15
View File
@@ -10,8 +10,10 @@
type ListableApp,
type Script,
ScriptService,
ResourceService,
type Flow,
type ListableRawApp,
type ListableResource,
type RunnableItem
} from '$lib/gen'
import { resource } from 'runed'
@@ -21,6 +23,7 @@
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ArrowDownUp,
Bot,
ChevronsDownUp,
ChevronsUpDown,
Code2,
@@ -65,7 +68,7 @@
const operatorBuilderFlows = useOperatorBuilderFlows()
interface Props {
subtab?: 'flow' | 'script' | 'app'
subtab?: 'flow' | 'script' | 'app' | 'agent'
showEditButtons?: boolean
}
@@ -104,7 +107,8 @@
options: [
{ value: 'script', label: 'Script' },
...(HOME_SEARCH_SHOW_FLOW ? [{ value: 'flow', label: 'Flow' }] : []),
{ value: 'app', label: 'App' }
{ value: 'app', label: 'App' },
{ value: 'agent', label: 'Agent' }
]
},
archived: { type: 'boolean' as const, label: 'Only archived' },
@@ -169,7 +173,15 @@
// depends on these (search, kind, archived, library, user-folder scope), so changing a
// searchbar chip reloads the server stream exactly as toggling the old controls did.
let filter = $derived((filterValues.val._default_ ?? '') as string)
let itemKind = $derived((filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'all')
let itemKind = $derived(
(filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'agent' | 'all'
)
// Agents are `ai_agent` resources, listed through the resource endpoint: the runnables
// endpoint has no such kind, so it is asked for nothing on the agent view and for every
// kind on the combined one.
let showsRunnables = $derived(itemKind !== 'agent')
let showsAgents = $derived(itemKind === 'all' || itemKind === 'agent')
let runnableKinds = $derived(itemKind === 'all' || itemKind === 'agent' ? undefined : itemKind)
let archived = $derived(!!filterValues.val.archived)
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
@@ -203,7 +215,7 @@
if (el) untrack(() => el.open())
})
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
canWrite: boolean
marked?: string
type?: U
@@ -221,6 +233,7 @@
type TableFlow = TableItem<Flow, 'flow'>
type TableApp = TableItem<ListableApp, 'app'>
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'>
// Folders that are data pipelines, surfaced as their own "Pipeline" entry
// (the member scripts are folded into it, not listed individually). Two
@@ -306,10 +319,15 @@
let flows: TableFlow[] | undefined = $state()
let apps: TableApp[] | undefined = $state()
let raw_apps: TableRawApp[] | undefined = $state()
// Starts empty rather than undefined: the runnables page decides when the list renders,
// and agents join it when their own request lands.
let agents: TableAgent[] = $state([])
let agentOwnerCounts: Record<string, number> = $state({})
let agentsGen = 0
// Monotonic fetch-order counter stamped onto each row as it arrives (see TableItem.ord).
let fetchOrd = 0
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp | TableAgent)[] = $state([])
let loading = $state(true)
@@ -352,6 +370,78 @@
return base as unknown as TableFlow | TableApp
}
/**
* The workspace's saved agents, as the kind the home page shows them as. Read from the
* resource listing, which the runnables' keyset order knows nothing of, so the order is applied
* here and the rows are stamped before every runnable ordinal: the agents lead as one block and
* a page of runnables keeps the server's order. Leading rather than trailing, or the list's
* first window would never reach them past a page of runnables.
*/
async function loadAgents(): Promise<void> {
const ws = $workspaceStore
const gen = ++agentsGen
if (!ws || !$userStore || !showsAgents || archived) {
agents = []
agentOwnerCounts = {}
return
}
const rows: ListableResource[] = []
try {
// Paged even though search and scoping run over the whole set here: the listing caps a
// page, and only the first carries the draft-only rows, so a short page ends it.
const perPage = 1000
for (let page = 1; ; page++) {
const batch = await ResourceService.listResource({
workspace: ws,
resourceType: 'ai_agent',
includeDraftOnly: true,
page,
perPage
})
rows.push(...batch)
if (batch.length < perPage || gen !== agentsGen) break
}
} catch (e: any) {
if (gen !== agentsGen) return
sendUserToast(`Failed to load agents: ${e?.body ?? e?.message ?? e}`, true)
agents = []
agentOwnerCounts = {}
return
}
if (gen !== agentsGen) return
// Counted before the owner scope: the chips and tree nodes list every owner.
const counts: Record<string, number> = {}
for (const r of rows) {
const owner = effectivePath(r).split('/').slice(0, 2).join('/')
counts[owner] = (counts[owner] ?? 0) + 1
}
agentOwnerCounts = counts
const scoped = ownerFilter
? rows.filter((r) => effectivePath(r).startsWith(ownerFilter + '/'))
: rows
const byTime = (r: ListableResource) => new Date(r.edited_at ?? 0).getTime()
const sorted = [...scoped].sort((a, b) => {
switch (sortOrder) {
case 'updated_asc':
return byTime(a) - byTime(b)
case 'name_asc':
return cmp(effectivePath(a), effectivePath(b))
case 'name_desc':
return cmp(effectivePath(b), effectivePath(a))
default:
return byTime(b) - byTime(a)
}
})
agents = sorted.map((r, i) => ({
...r,
summary: r.description || undefined,
canWrite: canWrite(r.path, (r.extra_perms ?? {}) as any, $userStore) && !$userStore?.operator,
ord: AGENT_ORD_BASE + i
}))
}
/** Below any ordinal a runnables page stamps (they count up from 0), so agents lead as a block. */
const AGENT_ORD_BASE = -1_000_000
// The merged, server-ordered, keyset-paginated source. `reset` reloads from
// the first page (order/filter change or workspace switch); otherwise it
// appends the next page. All three kinds arrive interleaved and are split into
@@ -368,6 +458,25 @@
// arrays (mixing streams) or clobber the pending reset's generation.
if (!reset && serverCursor === undefined) return
if (scripts === undefined) loading = true
// One load per reset: agents are fetched whole, so a load-more has nothing to append.
const agentsLoad = reset ? loadAgents() : undefined
if (!showsRunnables) {
// Supersedes a page still in flight, which would otherwise land in the agent view.
const gen = ++loadGen
serverCursor = undefined
hasMoreServer = false
// The rows on screen stay until the agents replace them, as a reload keeps them, or the
// view reads as empty in between.
await agentsLoad
if (gen !== loadGen) return
scripts = []
flows = []
apps = []
raw_apps = []
pipelineMemberFolders = new Set()
loading = false
return
}
if (reset) {
serverCursor = undefined
hasMoreServer = false
@@ -390,7 +499,7 @@
orderDesc,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
kinds: itemKind !== 'all' ? itemKind : undefined,
kinds: runnableKinds,
// Selecting an owner/folder scopes the paged stream to it server-side,
// so a folder's full contents load on demand rather than relying on the
// folder happening to be within the loaded browse window.
@@ -410,6 +519,12 @@
// A newer request superseded this one (e.g. order changed mid-flight); drop
// this response so a stale page/cursor can't be mixed with the new order.
if (gen !== loadGen) return
// Landed together with the agents fetched beside it, or a workspace holding only agents
// reads as empty until they arrive.
if (agentsLoad) {
await agentsLoad
if (gen !== loadGen) return
}
serverCursor = res.next_cursor ?? undefined
hasMoreServer = !!res.next_cursor
@@ -541,6 +656,13 @@
// Track the prefix as open first — even a no-op call (re-expanding a cached node)
// means it's on screen, so later reloads must refresh it.
openOwners.add(owner)
// The agent view has no runnables to page in: its rows are the loaded agents, which
// `treeSource` adds itself. Rows an owner held from another kind go with the switch.
if (!showsRunnables) {
treeOwnerItems = treeOwnerItems.filter((x) => !effectivePath(x).startsWith(`${owner}/`))
ownerLoad[owner] = { hasMore: false, loading: false, loaded: true, gen: treeGen }
return
}
const st = ownerLoad[owner]
// Only a load for the CURRENT generation blocks a new one. A load left in flight
// by a superseded generation (treeGen bumped on a sort/filter reload) has already
@@ -603,7 +725,7 @@
orderDesc,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
kinds: itemKind !== 'all' ? itemKind : undefined,
kinds: runnableKinds,
pathStart: prefix,
includeDraftOnly: true,
perPage: OWNER_PAGE_SIZE,
@@ -719,7 +841,7 @@
}
function filterItemsPathsBaseOnUserFilters(
item: TableScript | TableFlow | TableApp | TableRawApp,
item: TableScript | TableFlow | TableApp | TableRawApp | TableAgent,
filterUserFolders: boolean,
filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined
) {
@@ -885,7 +1007,15 @@
const f: string[] = []
if (filter !== '') f.push(`search “${filter}”`)
if (itemKind !== 'all')
f.push(itemKind === 'script' ? 'Scripts' : itemKind === 'flow' ? 'Flows' : 'Apps')
f.push(
itemKind === 'script'
? 'Scripts'
: itemKind === 'flow'
? 'Flows'
: itemKind === 'agent'
? 'Agents'
: 'Apps'
)
if (ownerFilter != undefined) f.push(ownerFilter)
if (labelFilter != undefined) f.push(`label “${labelFilter}”`)
if (archived) f.push('archived only')
@@ -973,6 +1103,7 @@
[() => $workspaceStore, () => archived, () => itemKind, () => includeWithoutMain],
async ([ws, showArchived, kind, withoutMain]) => {
if (!ws || showArchived) return undefined
if (kind === 'agent') return {}
try {
const res = await ScriptService.countRunnablesByOwner({
workspace: ws,
@@ -988,7 +1119,16 @@
}
}
)
let ownerCounts = $derived(ownerCountsRes.current)
// The runnables endpoint never counts an agent, so the loaded agents are added per owner.
let ownerCounts = $derived.by(() => {
const runnables = ownerCountsRes.current
if (runnables == undefined) return undefined
const counts = { ...runnables }
for (const [owner, n] of Object.entries(agentOwnerCounts)) {
counts[owner] = (counts[owner] ?? 0) + n
}
return counts
})
// The counts decide which owners the tree renders, so drawing it before they land
// would show every workspace folder and then prune it away. Hold the skeleton
// until the first response instead — it is fetched in parallel with the listing,
@@ -1169,7 +1309,8 @@
const kind = itemKind
// Any term/scope change restarts search paging.
searchCursor = undefined
if (term === '' || !ws || !$userStore) return
// Agents are all loaded, so the client-side search over them is already complete.
if (term === '' || !ws || !$userStore || kind === 'agent') return
const handle = setTimeout(async () => {
let res: { items: RunnableItem[]; next_cursor?: string }
try {
@@ -1225,7 +1366,7 @@
search: term,
showArchived: showArchived ? true : undefined,
includeWithoutMain: withoutMain ? true : undefined,
kinds: kind !== 'all' ? kind : undefined,
kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined,
pathStart: owner ? owner + '/' : undefined,
includeDraftOnly: true,
perPage: 1000,
@@ -1276,6 +1417,11 @@
...x,
type: 'raw_app' as 'raw_app',
time: new Date(x.edited_at).getTime()
})),
...agents.map((x) => ({
...x,
type: 'agent' as 'agent',
time: new Date(x.edited_at ?? 0).getTime()
}))
].sort(compareItems)
)
@@ -1336,10 +1482,12 @@
// injected as a node and its rows come from the on-demand `treeOwnerItems` store,
// so the tree never depends on which owners happen to be in the loaded window.
// Otherwise (scoped/search/label) the tree just groups the global `items`.
// Agents ride along in lazy mode too: the owner store is fed by the runnables endpoint,
// which never returns one, while the resource listing already holds them all.
let treeSource = $derived(
treeLazyMode
? treeOwnerItems.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
? [...treeOwnerItems, ...agents.map((x) => ({ ...x, type: 'agent' as 'agent' }))].filter(
(x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
)
: items
)
@@ -1672,7 +1820,8 @@
homeSelection.active &&
selectedIndex >= 0 &&
selectedIndex < displayedItems.length &&
displayedItems[selectedIndex].type !== 'raw_app'
displayedItems[selectedIndex].type !== 'raw_app' &&
displayedItems[selectedIndex].type !== 'agent'
) {
e.preventDefault()
homeSelection.toggle(
@@ -1808,6 +1957,14 @@
size="md"
{item}
/>
<ToggleButton
value="agent"
label="Agents"
icon={Bot}
selectedColor="#8b5cf6"
size="md"
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
@@ -1,5 +1,5 @@
import type { ListableApp, Script, Flow, ListableRawApp } from '$lib/gen'
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
import type { ListableApp, Script, Flow, ListableRawApp, ListableResource } from '$lib/gen'
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
canWrite: boolean
marked?: string
type?: U
@@ -14,8 +14,11 @@ type TableScript = TableItem<Script, 'script'>
type TableFlow = TableItem<Flow, 'flow'>
type TableApp = TableItem<ListableApp, 'app'>
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
/** A saved agent: an `ai_agent` resource the home page lists as a kind of its own. Its
* description stands in as the summary every other kind carries. */
type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'>
export type ItemType = TableScript | TableFlow | TableApp | TableRawApp
export type ItemType = TableScript | TableFlow | TableApp | TableRawApp | TableAgent
export interface FolderItem {
folderName: string
@@ -185,6 +188,8 @@ function getModifiedAt(item: ItemType): number {
return new Date(item.edited_at).getTime() || 0
} else if (item.type === 'raw_app') {
return new Date(item.edited_at).getTime() || 0
} else if (item.type === 'agent') {
return new Date(item.edited_at ?? 0).getTime() || 0
}
return 0
+10 -1
View File
@@ -53,7 +53,16 @@ export function onUserInput(handle: (kind: UserInputKind) => void): void {
const onEvent = (e: Event) => {
// A programmatic `dispatchEvent` is untrusted, which is what keeps the
// form's own settling from opening the gate it is gated by.
if (e.isTrusted) handle(kind)
if (!e.isTrusted) return
try {
handle(kind)
} catch (err) {
// Removing a focused field whose value changed fires `change` from inside
// the teardown that removes it, where Svelte refuses state writes. The
// gate then opens just after, which is still before anything is saved.
if (!String((err as Error)?.message).includes('state_unsafe_mutation')) throw err
queueMicrotask(() => handle(kind))
}
}
document.addEventListener(type, onEvent, true)
listeners.push([type, onEvent])
@@ -41,7 +41,7 @@
let tab = $state<Tab>('workspace')
let subtab: 'flow' | 'script' | 'app' = $state('script')
let subtab: 'flow' | 'script' | 'app' | 'agent' = $state('script')
const searchParams = useSearchParams(z.object({ search: z.string().nullable() }))
const getFilter = () => searchParams.search ?? ''
@@ -0,0 +1,5 @@
<!--
Never rendered — the sibling +page.ts redirects at the load phase, before
this component can mount. Kept as an empty file because SvelteKit only
registers a route when a +page.svelte exists.
-->
@@ -0,0 +1,6 @@
import { makeDraftAddLoad } from '$lib/draftAddRedirect'
import type { PageLoad } from './$types'
export const prerender = false
export const load: PageLoad = makeDraftAddLoad('agents/edit')
@@ -0,0 +1,31 @@
<script lang="ts">
import { untrack } from 'svelte'
import { page } from '$app/state'
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import { copilotInfo } from '$lib/aiStore'
import AgentEditorModal from '$lib/components/flows/content/AgentEditorModal.svelte'
import { openAgentEditor } from '$lib/components/flows/agentEditorStore.svelte'
let path = $derived(page.params.path ?? '')
/** Set by `/agents/add` on the path it minted: a missing agent there is a new one, not an error. */
let isNew = $derived(page.url.searchParams.get('new_draft') === 'true')
$effect(() => {
const target = { path, isNew }
untrack(() => openAgentEditor(target))
})
</script>
<div class="h-screen w-full">
<AgentEditorModal
layout="page"
enableAi={$copilotInfo.enabled}
owns={(t) => t.host === undefined}
onClose={(deployed) => goto(deployed ? `${base}/agents/get/${path}` : `${base}/?kind=agent`)}
onDeployed={(saved) =>
// Replaced, not pushed, when this URL stopped naming what it opens: Back would otherwise
// reopen `new_draft` on an agent that now exists, or a path a rename just moved away from.
goto(`${base}/agents/get/${saved}`, { replaceState: isNew || saved !== path })}
/>
</div>
@@ -0,0 +1,188 @@
<script lang="ts">
import { page } from '$app/state'
import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte'
import { resource } from 'runed'
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import { copilotInfo } from '$lib/aiStore'
import AgentEvalsModal from '$lib/components/flows/content/AgentEvalsModal.svelte'
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { userStore, workspaceStore } from '$lib/stores'
/**
* The deployed agent, as a flow's or a script's page shows theirs: a way to run it, a chat when
* its memory keeps the conversation and the inputs form otherwise, beside what it is.
*/
let path = $derived(page.params.path ?? '')
let ws = $derived($workspaceStore)
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let agent = $derived(host?.draftHandle())
let testPane = $derived(host?.testPaneHandle())
let config = $derived(agent?.state?.args)
let chatAvailable = $derived(keepsManagedMemory(config?.memory))
let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && !$userStore?.operator)
let configModal: AgentConfigModal | undefined = $state(undefined)
let shareModal: ShareModal | undefined = $state(undefined)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
let deleteOpen = $state(false)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
async function remove() {
if (ws && (await deleteAgent(ws, path))) await goto(`${base}/?kind=agent`)
}
// The workspace's, so a path change never waits on it and a menu never outlives its path.
const deployUiSettings = resource(
() => ws,
() => getDeployUiSettings()
)
// Operators get no menu, as on a flow's or a script's page.
let menuItems = $derived(
config != undefined && !$userStore?.operator
? agentMenuItems({
path,
canWrite: canEdit,
wsSpecific: agent?.state?.wsSpecific,
deployUiSettings: deployUiSettings.current,
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
onDelete: () => (deleteOpen = true)
}).map((item) => ({
label: item.displayName,
Icon: item.icon,
onclick: (e: MouseEvent) => item.action?.(e),
disabled: item.disabled,
color: item.type === 'delete' ? ('red' as const) : undefined
}))
: []
)
</script>
<ShareModal bind:this={shareModal} />
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<ConfirmationModal
open={deleteOpen}
title="Delete agent"
confirmationText="Delete"
on:canceled={() => (deleteOpen = false)}
on:confirmed={() => {
deleteOpen = false
remove()
}}
>
<span>Every flow that links {path} will fail at its agent step once it is deleted.</span>
</ConfirmationModal>
<main class="h-screen w-full flex flex-col">
<DetailPageHeader
summary={config ? agent?.state?.description : undefined}
{path}
{menuItems}
mainButtons={[
// Evaluating an agent builds datasets and runs against it: authoring, as editing is.
...(canEdit
? [
{
label: 'Evals',
buttonProps: {
variant: 'default',
unifiedSize: 'md',
startIcon: FlaskConical,
title: 'Run this agent against a dataset of cases',
onClick: () => evalsModal?.openModal()
}
},
{
label: 'Edit',
buttonProps: {
variant: 'accent',
unifiedSize: 'md',
startIcon: Pen,
href: `${base}/agents/edit/${path}`
}
}
]
: [])
]}
>
{#snippet leading_actions()}
<!-- Only an agent that keeps the conversation can chat: without managed memory every
message would be answered alone, so it is run from its inputs with nothing to switch. -->
{#if chatAvailable && testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="md"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="md"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{/snippet}
</DetailPageHeader>
<div class="flex-1 min-h-0">
{#key `${ws}:${path}`}
<AgentEditorHost
bind:this={host}
{path}
workspace={ws}
enableAi={$copilotInfo.enabled}
view
onOpenConfig={() => configModal?.open()}
>
{#snippet viewForm({ schema, run, loading, actions })}
<RunForm
runnable={{ schema, path }}
runAction={run}
schedulable={false}
detailed={false}
autofocus
{loading}
{actions}
/>
{/snippet}
</AgentEditorHost>
{/key}
</div>
</main>
{#if ws}
<AgentEvalsModal bind:this={evalsModal} agentPath={path} workspace={ws} />
{/if}
{#if config}
<AgentConfigModal bind:this={configModal} {config} toolSchema={(id) => host?.toolSchema(id)} />
{/if}