From 8c7dcbbda58c86743bf3822c422be7faf1e8df51 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 30 Sep 2026 18:40:05 +0200 Subject: [PATCH] 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) * fix: keep runnables out of the agents view and anchor a new agent once saved Co-Authored-By: Claude Opus 5.5 (1M context) * fix: autosave a new agent's first edit and list agents past one page Co-Authored-By: Claude Opus 5.5 (1M context) * feat: add agent detail and editor pages Co-Authored-By: Claude Opus 5.5 (1M context) * fix: explain when an agent cannot be run and drop the broken agent move Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep query params and a unique path when creating an agent Co-Authored-By: Claude Opus 5.5 (1M context) * fix: lead the home list with agents and keep rows while the agent view loads Co-Authored-By: Claude Opus 5.5 (1M context) * fix: treat a loading agent as undeployed when leaving the editor page Co-Authored-By: Claude Opus 5.5 (1M context) * feat: agent detail page config panel, run page on form runs, chat badge Co-Authored-By: Claude Opus 5.5 (1M context) * feat: agent configuration modal and draft paths like other new items Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep a new agent draft-free until the first input, land agents with runnables Co-Authored-By: Claude Opus 5.5 (1M context) * 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) * feat: new agents start with managed memory, editor form says how to turn it off Co-Authored-By: Claude Opus 5.5 (1M context) * fix: clearer managed memory hint in the agent editor form Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: share the agent editor's pane notice as a PaneNotice component Co-Authored-By: Claude Opus 5.5 (1M context) * fix: name a new agent after a path no resource holds Co-Authored-By: Claude Opus 5.5 (1M context) * fix: tell repeated tool names apart and hide permissions on draft-only agents Co-Authored-By: Claude Opus 5.5 (1M context) * feat: agent configuration beside the model in the chat composer and above the form Co-Authored-By: Claude Opus 5.5 (1M context) * feat: center the agent run form, configuration beside its Run button Co-Authored-By: Claude Opus 5.5 (1M context) * fix: icon-only agent configuration button beside Run Co-Authored-By: Claude Opus 5.5 (1M context) * feat: agents run on behalf of their deployer through a run-by-path endpoint Co-Authored-By: Claude Opus 5.5 (1M context) * 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) * 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) * 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) * 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:` 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) * 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) * 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) * docs: agent editor comments describe runs as the caller Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: agent editor pane notes use Alert Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: agent editor pane notes render as Alert Co-Authored-By: Claude Opus 5.5 (1M context) * fix: agent editor notes as regular Alerts, not banners Co-Authored-By: Claude Opus 5.5 (1M context) * 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) * 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) * feat: agent settings fields and cog, completing the rename Co-Authored-By: Claude Opus 5.5 (1M context) * 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) * 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) * 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) * fix: provider note lists asking for access as its own option Co-Authored-By: Claude Opus 5.5 (1M context) * fix: shorter provider note, without the header's buttons repeated Co-Authored-By: Claude Opus 5.5 (1M context) * 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) * 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) * 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) * 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) * 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) * 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) * fix: the scoped-read comment names the run-flow builder as it is Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...87436d0fe74f99f298e5c399233f0d247f2ec.json | 35 ++ ...e7436b203a3baa7ef0509cb8e6c52270ae3a9.json | 35 -- ...d4de49f63fb6dc4c8aab126dbe7cd611a862c.json | 23 + backend/tests/fixtures/jobs_read_auth.sql | 26 ++ backend/tests/jobs_read_auth.rs | 20 + backend/windmill-api-auth/src/scopes.rs | 16 +- .../tests/agent_runs.rs | 60 +++ backend/windmill-api/openapi.yaml | 52 +++ backend/windmill-api/src/agent_runs.rs | 184 ++++++++ backend/windmill-api/src/ai_evals/run.rs | 34 +- backend/windmill-api/src/jobs.rs | 16 +- backend/windmill-api/src/lib.rs | 1 + backend/windmill-store/src/resources.rs | 52 ++- frontend/src/lib/components/RunForm.svelte | 22 +- .../components/common/table/AgentRow.svelte | 122 +++++ .../lib/components/common/table/Row.svelte | 1 + .../components/common/table/RowIcon.svelte | 4 + .../copilot/chat/AssistantToolsSection.svelte | 10 +- .../details/DetailPageHeader.svelte | 29 +- .../src/lib/components/flows/agentActions.ts | 55 +++ .../lib/components/flows/agentDraft.svelte.ts | 87 +++- .../flows/agentEditorStore.svelte.ts | 5 +- .../flows/content/AgentConfigModal.svelte | 151 ++++++ .../flows/content/AgentEditorHost.svelte | 430 +++++++++++------- .../flows/content/AgentEditorModal.svelte | 426 +++++++++++------ .../flows/content/AgentEvalsModal.svelte | 49 ++ .../flows/content/AgentResourceBar.svelte | 29 +- .../flows/content/AgentSettings.svelte | 79 ++++ .../flows/conversations/FlowChat.svelte | 6 +- .../conversations/FlowChatInterface.svelte | 8 +- .../components/home/CreateActionsMenu.svelte | 16 + frontend/src/lib/components/home/Item.svelte | 18 +- .../src/lib/components/home/ItemsList.svelte | 187 +++++++- .../src/lib/components/home/treeViewUtils.ts | 11 +- frontend/src/lib/userDraftEditGate.ts | 11 +- .../src/routes/(root)/(logged)/+page.svelte | 2 +- .../(root)/(logged)/agents/add/+page.svelte | 5 + .../(root)/(logged)/agents/add/+page.ts | 6 + .../agents/edit/[...path]/+page.svelte | 31 ++ .../agents/get/[...path]/+page.svelte | 188 ++++++++ 40 files changed, 2108 insertions(+), 434 deletions(-) create mode 100644 backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json delete mode 100644 backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json create mode 100644 backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json create mode 100644 backend/windmill-api-integration-tests/tests/agent_runs.rs create mode 100644 backend/windmill-api/src/agent_runs.rs create mode 100644 frontend/src/lib/components/common/table/AgentRow.svelte create mode 100644 frontend/src/lib/components/flows/agentActions.ts create mode 100644 frontend/src/lib/components/flows/content/AgentConfigModal.svelte create mode 100644 frontend/src/lib/components/flows/content/AgentEvalsModal.svelte create mode 100644 frontend/src/lib/components/flows/content/AgentSettings.svelte create mode 100644 frontend/src/routes/(root)/(logged)/agents/add/+page.svelte create mode 100644 frontend/src/routes/(root)/(logged)/agents/add/+page.ts create mode 100644 frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte create mode 100644 frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte diff --git a/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json b/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json new file mode 100644 index 0000000000..e103a91c1e --- /dev/null +++ b/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json @@ -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" +} diff --git a/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json b/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json deleted file mode 100644 index 557466e8b7..0000000000 --- a/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json b/backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json new file mode 100644 index 0000000000..6ee5709618 --- /dev/null +++ b/backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value AS \"value: sqlx::types::Json\"\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c" +} diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql index d7d11228aa..980d68b711 100644 --- a/backend/tests/fixtures/jobs_read_auth.sql +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -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 `.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. diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index 9325f25652..fd2969a18f 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -456,6 +456,26 @@ async fn test_single_job_read_authorization(db: Pool) -> 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:` 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. diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 29ee0fe36a..b824936d2f 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -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:`. +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 { 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 { { 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. diff --git a/backend/windmill-api-integration-tests/tests/agent_runs.rs b/backend/windmill-api-integration-tests/tests/agent_runs.rs new file mode 100644 index 0000000000..b0302187cb --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/agent_runs.rs @@ -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) -> 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(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3cdd07b280..67e08077c1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs new file mode 100644 index 0000000000..d2e76dcca0 --- /dev/null +++ b/backend/windmill-api/src/agent_runs.rs @@ -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:` 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, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Query(run_query): Query, + Json(args): Json>>, +) -> 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\" + 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 { + 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)?) +} diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index 5d282c7e5b..42c2fcb2f2 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -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 { - 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 { 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 { - // 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 { +pub(crate) fn config_to_draft(value: serde_json::Value) -> Result { let mut config = match value { serde_json::Value::Object(map) => map, _ => return Err(Error::BadRequest("The agent is not an object".to_string())), diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 5115148f83..250247c0ef 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -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 (`.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, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 556163ce1c..1cd2b4101c 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -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"))] diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 48d6b19181..eeea790009 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -208,6 +208,25 @@ pub struct ListableResource { pub is_draft: Option, } +/// A row of the resource listing: the resource as a single read returns it, plus what only the +/// listing carries. +#[derive(FromRow, Serialize)] +pub struct ListedResource { + #[sqlx(flatten)] + #[serde(flatten)] + pub resource: ListableResource, + /// An `ai_agent`'s `memory` setting, the one part of its value a listing shows: whether it + /// keeps a conversation decides how it is offered. `value` stays unlisted for every type. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub agent_memory: Option, + /// 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, +} + #[derive(Deserialize)] pub struct CreateResource { pub path: String, @@ -324,7 +343,7 @@ async fn list_resources( Extension(user_db): Extension, Extension(db): Extension, Path(w_id): Path, -) -> JsonResult> { +) -> JsonResult> { let (per_page, offset) = paginate(pagination); let mut sqlb = SqlBuilder::select_from("resource") @@ -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::>(); 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 }); } } diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 510f774587..e5b4e2780a 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -123,6 +123,9 @@ args?: Record 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 @@ {/if} + {:else if actions} + +
+ +
{@render actions()}
+
{:else} + + {/if} + + 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} + diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 4fb90151c1..76cd30640a 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -39,6 +39,7 @@ | 'flow' | 'app' | 'raw_app' + | 'agent' | 'resource' | 'variable' | 'resource_type' diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 9f0395b924..a7c99345a3 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -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 @@ {:else if effectiveKind === 'script'} + {:else if effectiveKind === 'agent'} + {:else if effectiveKind === 'variable'} {:else if effectiveKind === 'resource'} diff --git a/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte index 288fab84b3..fe5c255596 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte @@ -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} > -
+
void + onclick: (e: MouseEvent) => void color?: 'red' + disabled?: boolean } - const { triggersCount, triggersState } = $state(getContext('TriggerContext')) + // An agent's page has no triggers, and so no context for them. + const { triggersCount, triggersState } = $state( + getContext('TriggerContext') ?? ({} as Partial) + ) 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?.()}
+ {@render leading_actions?.()} {#if allMenuItems.length > 0} {#key allMenuItems} {/key} {/if} - {#if wide.current} + {#if wide.current && errorHandlerKind && scriptOrFlowPath} { + 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 + } + ] +} diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index 5ee65cd98a..b76f9bb471 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -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//_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 { + 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 | 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_` 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}`) } ) diff --git a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts index 025a426d28..24a44df0ad 100644 --- a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts +++ b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts @@ -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(undefined) diff --git a/frontend/src/lib/components/flows/content/AgentConfigModal.svelte b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte new file mode 100644 index 0000000000..5fe009b57b --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte @@ -0,0 +1,151 @@ + + + + + {#snippet headerLeft()} +

+ What the agent runs with. Edit the agent to change it. +

+ {/snippet} + +
+
+ (section = id as Section)} + /> +
+ +
+ {#if section === 'model'} +
+ {#each settings as row (row.label)} +
+
{row.label}
+
{row.value}
+
+ {:else} + Not configured + {/each} +
+ {:else if section === 'instructions'} +
+ {#if systemPrompt} +

+ {systemPrompt} +

+ {:else} + No system message + {/if} +
+ {/if} + +
+ +
+
+
+
diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index f0d65068f0..9c2a57b43f 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -1,7 +1,8 @@ + +{#snippet configButton()} + + - -

Chat needs managed memory

-

- {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.'} -

- {#if chatGap.memoryCanTurnOn && !readOnly} - - {/if} -
- {:else if chatGap?.noStream} -
- - - {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.'} - - {#if chatGap.noStream === 'off' && !readOnly} - - {/if} -
- {/if} - -
- + + {/if} + + +
+
-
- {/if} + + + + + - - - + {/if} + {#if chatMounted} +
+ {#if chatGap?.memory} +
+ +

Chat needs managed memory

+

+ {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.'} +

+ {#if chatGap.memoryCanTurnOn && !readOnly} + + {/if} +
+ {:else if chatGap?.noStream} + + Each answer shows once its run ends. + {#if chatGap.noStream === 'off' && !readOnly} +
+ +
+ {/if} +
+ {/if} + +
+ +
+
+ {/if} + + {/snippet} - 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()} - - {#if version != undefined && !refused} - - v{version} - - {/if} - {/snippet} - {#snippet levelBadge()} - - {#if inEvals && !evalsLocation} - Beta - {/if} - {/snippet} - - {#snippet settings()} -
- {#if !inEvals && !refused} - - {#if testPane?.mode} - testPane?.mode, - (mode) => { - if (testPane) testPane.mode = mode - } - } - noWFull - > - {#snippet children({ item })} - - - {/snippet} - - {/if} - {#if readOnly} - - Read only - - {/if} - - {#if !draftOnly} - - {/if} - - {/if} -
- {/snippet} + {#if layout === 'modal'} + 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()} + + {:else}
- -
- draft?.deployed} - getCurrent={() => draft?.state} - onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')} - title="Deployed <> Unsaved agent changes" - /> +
+
+
+ {#each trail as segment, i (i)} + {#if i > 0} + + {/if} + {#if segment.onclick} + + {:else} + {segment.label} + {/if} + {#if i === 0} + {@render titleBadge()} + {/if} + {/each} + {@render levelBadge()} +
+ {#if description} + {description} + {/if} +
+
+ {@render settings()} +
+
+ {@render body()}
- -
- + {/if} {/key} + {#snippet titleBadge()} + + {#if version != undefined && !refused} + + v{version} + + {/if} + {/snippet} + {#snippet levelBadge()} + + {#if inEvals && !evalsLocation} + Beta + {/if} + {/snippet} + + {#snippet settings()} +
+ {#if !inEvals && !refused} + + {#if testPane?.mode && !inSettings} + testPane?.mode, + (mode) => { + if (testPane) testPane.mode = mode + } + } + noWFull + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {#if readOnly} + + Read only + + {/if} + {#if !inSettings} + + {/if} + + {/if} +
+ {/snippet} + {#snippet body()} +
+ +
+ draft?.deployed} + getCurrent={() => draft?.state} + onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')} + title="Deployed <> Unsaved agent changes" + /> +
+ + +
+ {/snippet} + + 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()} showAgentEditorTool(id)} {onSaved} + isNew={target?.isNew} /> {/snippet} + {#snippet settingsFields()} + {#if draft} + host?.pathError(), (error) => host?.setPathError(error)} + /> + {/if} + {/snippet} + + {#snippet settingsPage()} +
{@render settingsFields()}
+ {/snippet} + {#snippet evalsPage()} {/snippet} + {#if layout === 'page' && ws} + + {/if} + + + settingsDrawer?.closeDrawer()}> + {@render settingsFields()} + + + versionDrawer?.closeDrawer()} noPadding> + 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(undefined) + + export function openModal() { + location = undefined + open = true + } + + +{#if open} + + {#snippet titleBadge()} + {#if !location} + Beta + {/if} + {/snippet} + + +{/if} diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index 83be7c876e..dc7c0948eb 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -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 /** 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 } } ) @@ -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 @@
{:else if !providerOk}
- - This agent's model provider{#if providerPath} - ({providerPath}){/if} isn't accessible in this workspace. - Unlink to fork the agent, or gain access to the provider resource. + + + {#if providerPath} + You don't have access to {providerPath}. + {: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}
{/if} diff --git a/frontend/src/lib/components/flows/content/AgentSettings.svelte b/frontend/src/lib/components/flows/content/AgentSettings.svelte new file mode 100644 index 0000000000..449edfccff --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentSettings.svelte @@ -0,0 +1,79 @@ + + +{#if draft.state} +
+ + {#if readOnly} + {#if draft.state.labels?.length} +
+ {#each draft.state.labels as label (label)} + {label} + {/each} +
+ {/if} + {:else} + + {/if} + {#if deployTo.current} + + {/if} + +
+{/if} diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index 04671e061b..321caeb190 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -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') @@ -239,6 +242,7 @@ {wideLayout} {conversationKind} {subject} + extraSettings={composerSettings} /> {/if} diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index 51046cd03d..f6cc9af6f5 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -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} diff --git a/frontend/src/routes/(root)/(logged)/agents/add/+page.ts b/frontend/src/routes/(root)/(logged)/agents/add/+page.ts new file mode 100644 index 0000000000..38697b7210 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/agents/add/+page.ts @@ -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') diff --git a/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte new file mode 100644 index 0000000000..b4539d0a8e --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte @@ -0,0 +1,31 @@ + + +
+ 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 })} + /> +
diff --git a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte new file mode 100644 index 0000000000..f6de4c078e --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte @@ -0,0 +1,188 @@ + + + + + (deleteOpen = false)} + on:confirmed={() => { + deleteOpen = false + remove() + }} +> + Every flow that links {path} will fail at its agent step once it is deleted. + + +
+ evalsModal?.openModal() + } + }, + { + label: 'Edit', + buttonProps: { + variant: 'accent', + unifiedSize: 'md', + startIcon: Pen, + href: `${base}/agents/edit/${path}` + } + } + ] + : []) + ]} + > + {#snippet leading_actions()} + + {#if chatAvailable && testPane?.mode} + testPane?.mode, + (mode) => { + if (testPane) testPane.mode = mode + } + } + noWFull + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {/snippet} + +
+ {#key `${ws}:${path}`} + configModal?.open()} + > + {#snippet viewForm({ schema, run, loading, actions })} + + {/snippet} + + {/key} +
+
+ +{#if ws} + +{/if} + +{#if config} + host?.toolSchema(id)} /> +{/if}