mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +00:00
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:
co-authored by
Claude Opus 5.5
parent
ca44043e12
commit
8c7dcbbda5
+35
@@ -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"
|
||||
}
|
||||
-35
@@ -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"
|
||||
}
|
||||
+23
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)?)
|
||||
}
|
||||
@@ -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())),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
? ([
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user