diff --git a/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json b/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json new file mode 100644 index 0000000000..e103a91c1e --- /dev/null +++ b/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT\n CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\\.chat$', '')\n ELSE j.runnable_path END AS runnable_path,\n CASE\n WHEN a.agent THEN 'agents'\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id,\n LATERAL (SELECT j.kind = 'flowpreview'\n AND j.raw_flow->'modules'->1 IS NULL\n AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a\n WHERE j.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "scope_kind", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "launched_by_app", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec" +} diff --git a/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json b/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json deleted file mode 100644 index 557466e8b7..0000000000 --- a/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT j.runnable_path,\n CASE\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id\n WHERE j.workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "runnable_path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "scope_kind", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "launched_by_app", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - true, - null, - null - ] - }, - "hash": "84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9" -} diff --git a/backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json b/backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json new file mode 100644 index 0000000000..6ee5709618 --- /dev/null +++ b/backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value AS \"value: sqlx::types::Json\"\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c" +} diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql index d7d11228aa..980d68b711 100644 --- a/backend/tests/fixtures/jobs_read_auth.sql +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -65,6 +65,32 @@ INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, resu ('15151515-1515-1515-1515-151515151515', 'test-workspace', 1000, 'success'::job_status, '{"wrapped": "WRAPPED_FLOW_RESULT"}'); +-- Scoped to an agent. An agent run is a preview of the one-step flow the run endpoint builds +-- (step `__wm_agent_root`), filed under the agent's path, or `.chat` for a chat turn. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES ( + encode(sha256('RUN_SCOPED_AGENT_TOKEN'::bytea), 'hex'), 'RUN_AGENT', 'RUN_SCOPED_AGENT_TOKEN', + 'test2@windmill.dev', 'agent token', false, + ARRAY['jobs:run:agents:f/shared/agent1'] +); + +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, raw_flow +) VALUES + ('17171717-1717-1717-1717-171717171717', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flowpreview', 'deno', 'f/shared/agent1', 'flow', true, + '{"modules": [{"id": "__wm_agent_root", "value": {"type": "aiagent", "tools": []}}]}'), + ('18181818-1818-1818-1818-181818181818', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flowpreview', 'deno', 'f/shared/agent1.chat', 'flow', true, + '{"modules": [{"id": "__wm_agent_root", "value": {"type": "aiagent", "tools": []}}]}'); +INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES + ('17171717-1717-1717-1717-171717171717', 'test-workspace', 1000, 'success'::job_status, + '{"agent": "AGENT_RESULT"}'), + ('18181818-1818-1818-1818-181818181818', 'test-workspace', 1000, 'success'::job_status, + '{"agent": "AGENT_CHAT_RESULT"}'); + -- A token pairing an app scope with a run scope, as someone driving an app's components -- programmatically would build. `APP_INLINE_JOB` is an inline-script component run: no -- `jobs:run` scope can name its kind, so only the `apps:run` half puts it in reach. diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index 9325f25652..fd2969a18f 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -456,6 +456,26 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul ); } + // An agent run and a chat turn of it belong to the agent, readable by the token scoped to + // it and by no flow-scoped one. + for (job, expected) in [ + ("17171717-1717-1717-1717-171717171717", "AGENT_RESULT"), + ("18181818-1818-1818-1818-181818181818", "AGENT_CHAT_RESULT"), + ] { + let path = format!("completed/get_result/{job}"); + let (status, body) = get(&base, &path, Some("RUN_SCOPED_AGENT_TOKEN")).await; + assert!( + status.is_success() && body.contains(expected), + "agent-scoped token must read its agent's run {job} (got {status}): {body}" + ); + let (status, body) = get(&base, &path, Some("RUN_SCOPED_TOKEN")).await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "a flow-scoped token must not read an agent run {job} (got {status}): {body}" + ); + } + // An `apps:run:` scope is a start grant too: the inline-script component run it // launched — a kind no `jobs:run` scope can name — stays readable to a token scoped // to that app, and stays out of reach for one that is only scoped to run jobs. diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 29ee0fe36a..b824936d2f 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -621,12 +621,17 @@ const FLOW_JOBS: [&'static str; 6] = [ "jobs/run_and_stream/f", ]; +/// Runs of a saved AI agent: the server builds the run from the stored agent, so the route is +/// scoped by the agent's path like a deployed runnable's, as `jobs:run:agents:`. +const AGENT_JOBS: [&'static str; 1] = ["jobs/run/agent"]; + lazy_static::lazy_static! { static ref RUN_PATH_ACTIONS: Vec<&'static str> = { let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component", "apps_u/upload_s3_file"]; v.extend(SCRIPT_JOBS); v.extend(FLOW_JOBS); + v.extend(AGENT_JOBS); v }; } @@ -646,13 +651,13 @@ fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction { } } -/// Checks the route path to determine the runnable kind (either "flows" or "scripts"). +/// Checks the route path to determine the runnable kind ("agents", "flows" or "scripts"). /// /// The order of checks is important: /// - Flow-related paths are checked first to avoid false positives, as some flow paths /// (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`). /// -/// Returns `"flows"` or `"scripts"` based on the match, or `None` if no match is found. +/// Returns the kind based on the match, or `None` if no match is found. fn determine_kind_from_route(route_path: &str) -> Option { if route_path.starts_with("jobs") { // Preview/bundle runs execute arbitrary code with no deployed path, so @@ -666,6 +671,9 @@ fn determine_kind_from_route(route_path: &str) -> Option { { return None; } + if AGENT_JOBS.iter().any(|path| route_path.starts_with(path)) { + return Some("agents".to_string()); + } if FLOW_JOBS.iter().any(|path| route_path.starts_with(path)) { return Some("flows".to_string()); } else if SCRIPT_JOBS.iter().any(|path| route_path.starts_with(path)) { @@ -1725,6 +1733,10 @@ mod tests { scope_for_route("POST", "/api/w/ws/jobs/run/f/u/x/y").as_deref(), Some("jobs:run:flows") ); + assert_eq!( + scope_for_route("POST", "/api/w/ws/jobs/run/agent/u/x/y").as_deref(), + Some("jobs:run:agents") + ); // Preview/bundle runs have no deployed path and their handlers require the // broad `jobs:run` scope, so the derived scope must not carry a kind. diff --git a/backend/windmill-api-integration-tests/tests/agent_runs.rs b/backend/windmill-api-integration-tests/tests/agent_runs.rs new file mode 100644 index 0000000000..b0302187cb --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/agent_runs.rs @@ -0,0 +1,60 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +const AGENT: &str = "u/test-user-3/agent"; + +async fn run(base_url: &str, token: &str) -> anyhow::Result<(u16, String)> { + let resp = reqwest::Client::new() + .post(format!( + "{base_url}/w/test-workspace/jobs/run/agent/{AGENT}" + )) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "user_message": "hi" })) + .send() + .await?; + Ok((resp.status().as_u16(), resp.text().await?)) +} + +/// Reading an agent is what allows running it, and the run is the caller's own: `test-user-2`, a +/// plain member, runs `test-user-3`'s agent once it may read it, lending nobody's permissions. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_agent_run_needs_read_and_runs_as_caller(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', $1, $2, 'ai_agent', '{}', 'test-user-3')", + ) + .bind(AGENT) + .bind(json!({ "system_prompt": "hi" })) + .execute(&db) + .await?; + + let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?; + assert_eq!(status, 404, "an unreadable agent: {body}"); + + sqlx::query( + "UPDATE resource SET extra_perms = '{\"u/test-user-2\": false}' + WHERE workspace_id = 'test-workspace' AND path = $1", + ) + .bind(AGENT) + .execute(&db) + .await?; + let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?; + assert_eq!(status, 201, "a readable agent: {body}"); + + let (permissioned_as, created_by) = sqlx::query_as::<_, (String, String)>( + "SELECT permissioned_as, created_by FROM v2_job WHERE id = $1::uuid", + ) + .bind(&body) + .fetch_one(&db) + .await?; + assert_eq!(permissioned_as, "u/test-user-2"); + assert_eq!(created_by, "test-user-2"); + + Ok(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3cdd07b280..67e08077c1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -16180,6 +16180,47 @@ paths: a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", "user_seq" } }`. + /w/{workspace}/jobs/run/agent/{path}: + post: + summary: run a saved AI agent + description: | + Runs the `ai_agent` resource at `path` as stored, for any caller who can read it + (operators included), as the caller, who needs access to the agent's AI resource and to + whatever its tools use. With `memory_id`, the run is a turn + of the caller's conversation with the agent; without, a single run. + operationId: runAgent + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + - name: memory_id + description: The conversation this turn belongs to. A uuid is used as is; any other string is hashed within the workspace and agent. + in: query + schema: + type: string + requestBody: + description: the run's inputs + required: true + content: + application/json: + schema: + type: object + properties: + user_message: + type: string + user_attachments: + type: array + items: {} + responses: + "201": + description: job created + content: + text/plain: + schema: + type: string + format: uuid + /w/{workspace}/jobs/run_wait_result/preview_flow: post: summary: run flow preview and wait for result @@ -31991,6 +32032,17 @@ components: (over a deployed row or a synthesized draft-only row). Frontend appends a `*` to the displayed name. type: boolean + agent_memory: + description: | + For an `ai_agent` resource only, its `memory` setting: the one part + of the value a listing returns, since it decides whether the agent + keeps a conversation. + draft_path: + type: string + description: | + On a draft-only row, the path its editor has staged when it + differs from the storage path (e.g. a never-deployed item parked at + `u/{user}/draft_{uuid}`). required: - path - resource_type diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs new file mode 100644 index 0000000000..d2e76dcca0 --- /dev/null +++ b/backend/windmill-api/src/agent_runs.rs @@ -0,0 +1,184 @@ +//! Running a saved AI agent from its own page, and the one-step flow its runs and evals share. +//! +//! The run is built here from the stored agent rather than sent by the client, which is what lets +//! anyone who can read the agent run it, operators included, the way a deployed flow runs by path: +//! a preview takes arbitrary code, this takes only the run's inputs. It runs as the caller, who +//! needs access to the agent's AI resource and to whatever its tools use. + +use std::collections::HashMap; + +use axum::{ + extract::{Path, Query}, + Extension, Json, +}; +use hyper::StatusCode; +use serde_json::value::RawValue; +use windmill_api_auth::check_scopes; +use windmill_common::{ + db::UserDB, + error::{Error, Result}, + jobs::JobPayload, + users::username_to_permissioned_as, + utils::{not_found_if_none, StripPath}, +}; +use windmill_queue::{push, PushArgs, PushIsolationLevel}; + +use crate::ai_evals::run::config_to_draft; +use crate::ai_evals::subject::AgentDraft; +use crate::db::{ApiAuthed, DB}; +use crate::jobs::{handle_chat_conversation_messages, set_flow_memory_id, RunJobQuery}; + +/// The id the agent's step carries in the run, as the editor's chat names it. A token scoped to +/// `jobs:run:agents:` reads its runs back by it (`require_job_within_run_scope`). +const AGENT_NODE_ID: &str = "__wm_agent_root"; + +/// A turn of the agent's chat when `memory_id` names a conversation, a single run otherwise. +/// Returns the job id. +pub(crate) async fn run_agent( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Query(run_query): Query, + Json(args): Json>>, +) -> Result<(StatusCode, String)> { + #[cfg(feature = "enterprise")] + crate::jobs::check_license_key_valid().await?; + + let path = path.to_path(); + check_scopes(&authed, || format!("jobs:run:agents:{path}"))?; + + // Read through the caller's own permissions: reading the agent is what allows running it. + let mut tx = user_db.clone().begin(&authed).await?; + let value = sqlx::query_scalar!( + "SELECT value AS \"value: sqlx::types::Json\" + FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'", + w_id, + path + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + let Some(sqlx::types::Json(value)) = not_found_if_none(value, "Agent", path)? else { + return Err(Error::BadRequest(format!( + "Agent {path} has no configuration" + ))); + }; + let config = config_to_draft(value)?; + + // A chat turn is filed where the agent's chat lists its conversations, which a flow cannot + // name (flow paths carry no `.`), so the two never share a conversation list. + let chat = run_query.memory_id.is_some(); + let run_path = if chat { + format!("{path}.chat") + } else { + path.to_string() + }; + // A step memory id would replace the conversation's, and the agent would forget the turns + // before; history comes from the conversation alone. + let flow_value = agent_step_flow( + &config, + AGENT_NODE_ID, + chat, + &["memory_id", "previous_messages"], + )?; + + let push_authed = authed.clone().into(); + let (uuid, mut tx) = push( + &db, + PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), + &w_id, + JobPayload::RawFlow { + value: flow_value, + path: Some(run_path.clone()), + restarted_from: None, + }, + PushArgs::from(&args), + authed.display_username(), + &authed.email, + username_to_permissioned_as(&authed.username), + authed.token_prefix.as_deref(), + authed.username_override.as_deref(), + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + None, + None, + None, + Some(&push_authed), + false, + None, + authed.trigger_or_fallback(None), + None, + ) + .await?; + + if let Some(memory_id) = run_query.memory_key(&w_id, &run_path) { + set_flow_memory_id(&mut tx, uuid, memory_id).await?; + } + if chat { + // A real conversation, not a test one: this is the agent as deployed, not a draft. + handle_chat_conversation_messages( + &mut tx, + &authed, + &w_id, + &run_path, + &run_query, + args.get("user_message"), + uuid, + false, + &args, + ) + .await?; + } + tx.commit().await?; + + Ok((StatusCode::CREATED, uuid.to_string())) +} + +/// The agent as a one-step flow, validated by deserializing through `FlowValue` rather than +/// trusted as raw JSON. Its own transforms are the step's, minus `without`, and the run's inputs +/// supply the message and the attachments over the top. Always inlined, never a link to the +/// resource: a linked step would resolve the agent when it runs, not as it was read here. +pub(crate) fn agent_step_flow( + config: &AgentDraft, + id: &str, + chat: bool, + without: &[&str], +) -> Result { + let mut input_transforms = match &config.input_transforms { + serde_json::Value::Object(map) => map.clone(), + _ => serde_json::Map::new(), + }; + for key in without { + input_transforms.remove(*key); + } + for key in ["user_message", "user_attachments"] { + input_transforms.insert( + key.to_string(), + serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }), + ); + } + let mut flow = serde_json::json!({ + "modules": [{ + "id": id, + "value": { + "type": "aiagent", + "tools": config.tools, + "input_transforms": serde_json::Value::Object(input_transforms), + } + }] + }); + if chat { + flow["chat_input_enabled"] = serde_json::json!(true); + } + Ok(serde_json::from_value(flow)?) +} diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index 5d282c7e5b..42c2fcb2f2 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -248,7 +248,7 @@ fn build_run_flow( /// The agent step, reading its case from the iteration rather than from the flow's arguments. fn agent_module(config: &AgentDraft) -> Result { - let flow = build_case_flow(config)?; + let flow = crate::agent_runs::agent_step_flow(config, AGENT_NODE_ID, false, &[])?; let mut value = serde_json::to_value(&flow.modules[0].value)?; if let Some(map) = value.as_object_mut() { let transforms = map @@ -269,36 +269,6 @@ fn agent_module(config: &AgentDraft) -> Result { Ok(serde_json::json!({ "id": AGENT_NODE_ID, "value": value })) } -/// The agent step as a one-module flow, so the module shape is validated by deserializing -/// through `FlowValue` rather than trusted as raw JSON. -fn build_case_flow(config: &AgentDraft) -> Result { - // The configuration runs exactly as authored: its own brain transforms are the module's, and - // the case supplies the message and the attachments over the top. - let mut input_transforms = match &config.input_transforms { - serde_json::Value::Object(map) => map.clone(), - _ => serde_json::Map::new(), - }; - for key in ["user_message", "user_attachments"] { - input_transforms.insert( - key.to_string(), - serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }), - ); - } - - // Always inlined, never a link to the resource: a linked step would resolve the agent when - // each case runs, which is the one thing a run of a named version must not do. - let mut agent_value = serde_json::Map::new(); - agent_value.insert("type".to_string(), serde_json::json!("aiagent")); - agent_value.insert("tools".to_string(), serde_json::json!(config.tools)); - agent_value.insert( - "input_transforms".to_string(), - serde_json::Value::Object(input_transforms), - ); - Ok(serde_json::from_value(serde_json::json!({ - "modules": [{ "id": AGENT_NODE_ID, "value": serde_json::Value::Object(agent_value) }] - }))?) -} - /// How many times the agent has been saved, not the identity of the row holding that value: runs /// are named by it and compared by it, so it has to be the resource's own count rather than a /// sequence the whole instance shares. @@ -348,7 +318,7 @@ pub(crate) async fn require_agent( /// transforms, its tools the module's tools. The same conversion for a draft and for what is /// deployed, so the two hash comparably — which is what lets a draft run be recognised as the /// version it became. -fn config_to_draft(value: serde_json::Value) -> Result { +pub(crate) fn config_to_draft(value: serde_json::Value) -> Result { let mut config = match value { serde_json::Value::Object(map) => map, _ => return Err(Error::BadRequest("The agent is not an object".to_string())), diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 5115148f83..250247c0ef 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -285,6 +285,7 @@ pub fn workspaced_service() -> Router { ) .route("/add_batch_jobs/{n}", post(add_batch_jobs)) .route("/run/preview_flow", post(run_preview_flow_job)) + .route("/run/agent/{*path}", post(crate::agent_runs::run_agent)) .route( "/run_wait_result/preview_flow", post(run_wait_result_preview_flow), @@ -1897,7 +1898,10 @@ async fn require_job_within_run_scope( // NULL for a job no such scope reaches directly (previews, dependency jobs, // flow-inlined scripts) — those are still readable as a step of a matching flow, // through their ancestors. A `singlestepflow` wraps either a script or a flow, so it - // projects onto the wrapped runnable the same way the batch-rerun query does. + // projects onto the wrapped runnable the same way the batch-rerun query does. An agent + // run is a preview of the one-step flow `agent_runs::agent_step_flow` builds, filed under the + // agent's path (`.chat` for a chat turn); the editor's own runs of it look the same, + // and are runs of that agent too. let chain = sqlx::query!( r#"WITH RECURSIVE chain(id, parent_job) AS ( SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2 @@ -1905,8 +1909,11 @@ async fn require_job_within_run_scope( SELECT j.id, j.parent_job FROM v2_job j JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2 ) - SELECT j.runnable_path, + SELECT + CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\.chat$', '') + ELSE j.runnable_path END AS runnable_path, CASE + WHEN a.agent THEN 'agents' WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts' WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows' WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN @@ -1919,7 +1926,10 @@ async fn require_job_within_run_scope( ) = 'flow' THEN 'flows' ELSE 'scripts' END END AS scope_kind, CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app - FROM v2_job j JOIN chain c ON c.id = j.id + FROM v2_job j JOIN chain c ON c.id = j.id, + LATERAL (SELECT j.kind = 'flowpreview' + AND j.raw_flow->'modules'->1 IS NULL + AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a WHERE j.workspace_id = $2"#, job_id, w_id, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 556163ce1c..1cd2b4101c 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -124,6 +124,7 @@ pub mod storage_list_ee; mod storage_list_oss; mod workspace_dependencies; +mod agent_runs; mod ai_evals; mod approvals; #[cfg(all(feature = "enterprise", feature = "private"))] diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 48d6b19181..eeea790009 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -208,6 +208,25 @@ pub struct ListableResource { pub is_draft: Option, } +/// A row of the resource listing: the resource as a single read returns it, plus what only the +/// listing carries. +#[derive(FromRow, Serialize)] +pub struct ListedResource { + #[sqlx(flatten)] + #[serde(flatten)] + pub resource: ListableResource, + /// An `ai_agent`'s `memory` setting, the one part of its value a listing shows: whether it + /// keeps a conversation decides how it is offered. `value` stays unlisted for every type. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub agent_memory: Option, + /// On a draft-only row, the path its editor has staged when it differs from the storage path + /// (a new item parked at `u/{user}/draft_{uuid}`), as the other kinds' listings report it. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_path: Option, +} + #[derive(Deserialize)] pub struct CreateResource { pub path: String, @@ -324,7 +343,7 @@ async fn list_resources( Extension(user_db): Extension, Extension(db): Extension, Path(w_id): Path, -) -> JsonResult> { +) -> JsonResult> { let (per_page, offset) = paginate(pagination); let mut sqlb = SqlBuilder::select_from("resource") @@ -346,6 +365,7 @@ async fn list_resources( "resource.labels", "folder_labels(resource.workspace_id, resource.path) as inherited_labels", "ws_specific.path IS NOT NULL as ws_specific", + "CASE WHEN resource.resource_type = 'ai_agent' THEN resource.value->'memory' END as agent_memory", ]) // Scalar EXISTS flags the authed user's per-user draft without fanning rows out. .field( @@ -430,11 +450,11 @@ async fn list_resources( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "resources", "read"); - let mut rows = sqlx::query_as::<_, ListableResource>(&sql) + let mut rows = sqlx::query_as::<_, ListedResource>(&sql) .fetch_all(&mut *tx) .await? .into_iter() - .filter(|r| allowed(&r.path)) + .filter(|r| allowed(&r.resource.path)) .collect::>(); tx.commit().await?; @@ -469,14 +489,18 @@ async fn list_resources( let v: serde_json::Value = serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); // ResourceEditor's `ResourceState`: { path, description, args, labels?, wsSpecific, resource_type? } - let path = v - .get("path") - .and_then(|s| s.as_str()) - .unwrap_or("") - .to_string(); - if path.is_empty() || !allowed(&path) { + // Listed at the draft's key rather than its `path`: an editor keys the draft on the path + // it opened, which is the only one a draft read can find, while `path` moves on rename. + // The staged `path` is reported beside it as `draft_path`. + let path = row.path; + if !allowed(&path) { continue; } + let draft_path = v + .get("path") + .and_then(|s| s.as_str()) + .filter(|p| !p.is_empty() && *p != path) + .map(str::to_string); let description = v .get("description") .and_then(|x| x.as_str()) @@ -507,8 +531,13 @@ async fn list_resources( }) }); let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + let agent_memory = if resource_type == "ai_agent" { + value.as_ref().and_then(|a| a.get("memory")).cloned() + } else { + None + }; - rows.push(ListableResource { + let resource = ListableResource { workspace_id: w_id.clone(), path, value, @@ -530,7 +559,8 @@ async fn list_resources( draft_only: Some(true), // Synthesized rows are the authed user's draft. is_draft: Some(true), - }); + }; + rows.push(ListedResource { resource, agent_memory, draft_path }); } } diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 510f774587..e5b4e2780a 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -123,6 +123,9 @@ args?: Record jsonView?: boolean isValid?: boolean + /** Controls beside the Run button of a form that cannot schedule, in the row a + * schedulable one gives its Advanced options. */ + actions?: import('svelte').Snippet } let { @@ -141,7 +144,8 @@ overrideTagNote = undefined, args = $bindable(), jsonView = false, - isValid = $bindable(true) + isValid = $bindable(true), + actions = undefined }: Props = $props() let showPsCommonParams = $derived( @@ -385,6 +389,22 @@ {/if} + {:else if actions} + +
+ +
{@render actions()}
+
{:else} + + {/if} + + agentMenuItems({ + deployUiSettings: await getDeployUiSettings(), + path: agent.path, + canWrite: agent.canWrite, + draftOnly: Boolean(agent.draft_only), + wsSpecific: agent.ws_specific, + onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'), + onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'), + onDelete: (event) => { + const { path } = agent + if (event?.shiftKey) remove(path) + else deleteConfirmedCallback = () => remove(path) + } + })} + on:open={() => { + menuOpen = true + }} + /> + {/snippet} + diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 4fb90151c1..76cd30640a 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -39,6 +39,7 @@ | 'flow' | 'app' | 'raw_app' + | 'agent' | 'resource' | 'variable' | 'resource_type' diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 9f0395b924..a7c99345a3 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -8,6 +8,7 @@ import AzureIcon from '$lib/components/icons/AzureIcon.svelte' import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte' import { + Bot, Boxes, Calendar, Code2, @@ -29,6 +30,7 @@ | 'app' | 'raw_app' | 'raw_app_file' + | 'agent' | 'resource' | 'variable' | 'resource_type' @@ -99,6 +101,8 @@ {:else if effectiveKind === 'script'} + {:else if effectiveKind === 'agent'} + {:else if effectiveKind === 'variable'} {:else if effectiveKind === 'resource'} diff --git a/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte index 288fab84b3..fe5c255596 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte @@ -21,10 +21,13 @@ they follow the mode, the connected servers and the workspace's AI settings. tools, providerTools, active, - blocksClose = $bindable() + blocksClose = $bindable(), + description = "What the assistant can call in this session: the built-in tools, whatever the connected MCP servers expose, and what the model's provider runs itself." }: { tools: ToolSummary[] providerTools: ProviderToolSummary[] + /** What the list is, under its heading. */ + description?: string /** Whether this is the panel on screen. Gates the detail page's build, which pulls * in the schema table and its syntax highlighter. */ active: boolean @@ -145,10 +148,7 @@ they follow the mode, the connected servers and the workspace's AI settings. onkeydown={highlight.onKeydown} onpointermove={highlight.pointerMoved} > -
+
void + onclick: (e: MouseEvent) => void color?: 'red' + disabled?: boolean } - const { triggersCount, triggersState } = $state(getContext('TriggerContext')) + // An agent's page has no triggers, and so no context for them. + const { triggersCount, triggersState } = $state( + getContext('TriggerContext') ?? ({} as Partial) + ) interface Props { mainButtons?: MainButton[] menuItems?: MenuItemButton[] summary?: string path?: string - tag: string | undefined - errorHandlerKind: 'flow' | 'script' - scriptOrFlowPath: string - errorHandlerMuted: boolean | undefined + tag?: string | undefined + /** Unset for what has no workspace error handler to mute, such as an agent. */ + errorHandlerKind?: 'flow' | 'script' + scriptOrFlowPath?: string + errorHandlerMuted?: boolean | undefined labels?: string[] | undefined inheritedLabels?: string[] | undefined onSaved?: (newPath: string) => void children?: import('svelte').Snippet trigger_badges?: import('svelte').Snippet + /** Controls ahead of the menu, such as the way an agent's page runs it. */ + leading_actions?: import('svelte').Snippet } let { @@ -64,7 +71,8 @@ inheritedLabels = undefined, onSaved, children, - trigger_badges + trigger_badges, + leading_actions }: Props = $props() const dispatch = createEventDispatcher() @@ -81,6 +89,7 @@ } async function toggleErrorHandler() { + if (!errorHandlerKind || !scriptOrFlowPath) return const next = await toggleWorkspaceErrorHandler( errorHandlerKind, scriptOrFlowPath, @@ -99,7 +108,7 @@ disabled: b.buttonProps.disabled, type: 'action' as const })), - ...(wide.current + ...(wide.current || !errorHandlerKind ? [] : [ { @@ -113,6 +122,7 @@ displayName: item.label, icon: item.Icon, action: item.onclick, + disabled: item.disabled, type: item.color === 'red' ? ('delete' as const) : ('action' as const), separatorTop: i === 0 && !wide.current })) @@ -176,12 +186,13 @@ {@render trigger_badges?.()}
+ {@render leading_actions?.()} {#if allMenuItems.length > 0} {#key allMenuItems} {/key} {/if} - {#if wide.current} + {#if wide.current && errorHandlerKind && scriptOrFlowPath} { + try { + await ResourceService.deleteResource({ workspace, path }) + sendUserToast(`Deleted agent ${path}`) + return true + } catch (err) { + sendUserToast(`Could not delete agent ${path}: ${err}`, true) + return false + } +} + +/** An agent's menu, as its home row and its page both show it. */ +export function agentMenuItems(agent: { + path: string + canWrite: boolean + /** Never deployed: no resource yet to hold permissions or to deploy onward. */ + draftOnly?: boolean + wsSpecific?: boolean + /** Undefined until loaded, when nothing is offered for deploy. */ + deployUiSettings: WorkspaceDeployUISettings | undefined + onPermissions: () => void + onDeploy: () => void + onDelete: (event?: MouseEvent) => void +}): Item[] { + const deployable = + !agent.wsSpecific && + !agent.draftOnly && + isDeployable('resource', agent.path, agent.deployUiSettings) + return [ + { + displayName: 'Permissions', + icon: Shield, + disabled: !agent.canWrite || Boolean(agent.draftOnly), + action: agent.onPermissions + }, + ...(deployable + ? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }] + : []), + { + displayName: 'Delete', + icon: Trash, + type: 'delete' as const, + disabled: !agent.canWrite, + action: agent.onDelete + } + ] +} diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index 5ee65cd98a..b76f9bb471 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -7,6 +7,11 @@ import { canWrite } from '$lib/utils' import { userStore } from '$lib/stores' import { getUserExt } from '$lib/user' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' +import { UserDraft } from '$lib/userDraft.svelte' +import { onUserInput } from '$lib/userDraftEditGate' +import { DEFAULT_AGENT_MEMORY } from './agentFormFields' +import { getUsernameForNamespace } from '$lib/userNamespace' +import { random_adj } from '$lib/components/random_positive_adjetive' import { useTriggerDraftSync, type TriggerDraftSync } from '../triggers/useTriggerDraftSync.svelte' import { logReusableAgentUsage } from './agentTelemetry' import { @@ -177,10 +182,30 @@ async function writeAgentResource( return { ok: true } } +/** A `u//_agent` no resource holds yet, as the path field picks one for a new + * script or flow. Gives up on availability after a few draws rather than hold the editor. */ +async function freeAgentPath(workspace: string): Promise { + const mint = () => `u/${getUsernameForNamespace()}/${random_adj()}_agent` + let candidate = mint() + for (let i = 0; i < 10; i++) { + const taken = await ResourceService.existsResource({ workspace, path: candidate }).catch( + () => false + ) + if (!taken) break + candidate = mint() + } + return candidate +} + export interface AgentDraftOptions { /** The `ai_agent` resource being edited. */ path: () => string | undefined workspace: () => string | undefined + /** A missing row is a new agent to start empty, not a load failure. */ + isNew?: () => boolean + /** Only the deployed agent, as a page that runs it shows it: no draft is loaded, restored or + * written, and a write from another tab does not land here. */ + deployedOnly?: () => boolean } export interface AgentDraftHandle { @@ -221,7 +246,8 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { const sync = useTriggerDraftSync({ itemKind: 'resource', - path: () => opts.path() ?? '', + // An empty path holds no draft handle, which is what keeps a deployed-only view off the draft. + path: () => (opts.deployedOnly?.() ? '' : (opts.path() ?? '')), workspace: () => opts.workspace(), drawerLoading: () => loading || refusal != null, // `$state.snapshot` deep-reads, so the sync effects re-run when a nested field of `args` @@ -234,6 +260,20 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { deployed: () => deployed as Record | undefined }) + /** A new agent has no deployed value for the sync to absorb the form's settling into, so until + * the user's first input the draft cell follows the form as a seed instead: what the editor + * fills in on its own, and the name the path field shows, is not an edit to save. */ + let seedNewUntilInput = $state<{ ws: string; path: string } | undefined>(undefined) + onUserInput(() => { + seedNewUntilInput = undefined + }) + $effect(() => { + const target = seedNewUntilInput + if (!target || !state) return + const settled = $state.snapshot(state) + untrack(() => UserDraft.seed('resource', target.path, settled, { workspace: target.ws })) + }) + function refuse(reason: string) { loading = false refusal = reason @@ -260,12 +300,13 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { loadedFor = key loading = true refusal = undefined + seedNewUntilInput = undefined // The user alongside the resource, as the generic resource editor loads it: a session or // fork editor operates on a workspace that is not the one being navigated, and groups, // folders and the admin flag are all per workspace, so the nav user would answer for the // wrong membership in both directions. Promise.all([ - ResourceService.getResource({ workspace: ws, path, getDraft: true }), + ResourceService.getResource({ workspace: ws, path, getDraft: !opts.deployedOnly?.() }), getUserExt(ws).catch(() => undefined) ]) // The rejection handler is `then`'s second argument rather than a trailing `catch`, so @@ -319,18 +360,48 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { // conflict or failure for the key: a conflict is deliberately sticky (the retry // keeps the same baseline), and nothing else mounts a resolver for `resource` // drafts, so re-opening the agent is the only place it can be resolved. - UserDraftDbSyncer.recordRemoteSync( - { workspace: ws, itemKind: 'resource', path }, - (r as { draft_saved_at?: string }).draft_saved_at - ) + if (!opts.deployedOnly?.()) { + UserDraftDbSyncer.recordRemoteSync( + { workspace: ws, itemKind: 'resource', path }, + (r as { draft_saved_at?: string }).draft_saved_at + ) + } loading = false await sync.maybeRestore() }, - (err) => { + async (err) => { + if (loadedFor !== key) return + // Nothing is written until the first edit: the sync saves only on user input, and + // the first deploy creates the resource at whatever path the form then holds. The + // draft stays at the minted `draft_` storage path, and the form starts on the + // free name a new script or flow gets. Named here rather than by the path field: a + // name minted after the seed below would differ from it, and the first click would + // save it. + if (opts.isNew?.() && (err as { status?: number })?.status === 404) { + const name = await freeAgentPath(ws) + if (loadedFor !== key) return + noDeployed = true + deployed = undefined + canWriteResource = true + state = { + path: name, + description: '', + // Opens on a working chat, the way a new agent is first tried. The editor says + // what memory is for, and how to turn it off, while it is on. + args: { memory: structuredClone(DEFAULT_AGENT_MEMORY) }, + resource_type: 'ai_agent', + wsSpecific: false + } + loading = false + // Seeds the draft cell with the empty agent, or its first-write guard swallows the + // first edit. Not `sync.maybeRestore`: with nothing deployed to compare against, it + // takes the form for a restored draft and autosaves it before any input. + seedNewUntilInput = { ws, path } + return + } // A failed load knows neither the resource's type nor its value, so it refuses: // clearing `loading` alone would let the sync restore a persisted draft into a form // that would then deploy over a resource nobody read. - if (loadedFor !== key) return refuse(`Could not load agent ${path}: ${err}`) } ) diff --git a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts index 025a426d28..24a44df0ad 100644 --- a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts +++ b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts @@ -14,10 +14,13 @@ export interface AgentEditorTarget { workspace?: string toolId?: string /** A level of the editor that is not the form. Mutually exclusive with `toolId`. */ - view?: 'evals' + view?: 'evals' | 'settings' /** Where to re-resolve a graph's tool nodes after a deploy, when opened from a flow step. Only a * real flow sets it: the agent editor offers no way to open a second editor from inside itself. */ host?: { flowPath: string; moduleId: string } + /** The path was minted for an agent that does not exist yet: a missing row is an empty agent to + * start from rather than a load failure. */ + isNew?: boolean } let target = $state(undefined) diff --git a/frontend/src/lib/components/flows/content/AgentConfigModal.svelte b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte new file mode 100644 index 0000000000..5fe009b57b --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte @@ -0,0 +1,151 @@ + + + + + {#snippet headerLeft()} +

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

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

+ {systemPrompt} +

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

Chat needs managed memory

-

- {chatGap.memoryCanTurnOn - ? 'Without it, every message would be answered without the ones before it.' - : 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'} -

- {#if chatGap.memoryCanTurnOn && !readOnly} - - {/if} -
- {:else if chatGap?.noStream} -
- - - {chatGap.noStream === 'image' - ? 'Image answers do not stream: each one shows once its run ends.' - : 'Streaming is off: each answer shows once its run ends.'} - - {#if chatGap.noStream === 'off' && !readOnly} - - {/if} -
- {/if} - -
- + + {/if} + + +
+
-
- {/if} + + + + + - - - + {/if} + {#if chatMounted} +
+ {#if chatGap?.memory} +
+ +

Chat needs managed memory

+

+ {chatGap.memoryCanTurnOn + ? 'Without it, every message would be answered without the ones before it.' + : 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'} +

+ {#if chatGap.memoryCanTurnOn && !readOnly} + + {/if} +
+ {:else if chatGap?.noStream} + + Each answer shows once its run ends. + {#if chatGap.noStream === 'off' && !readOnly} +
+ +
+ {/if} +
+ {/if} + +
+ +
+
+ {/if} + + {/snippet} - true, (open) => !open && close()} - kind="X" - fillHeight - enterConfirms={false} - paginated - title={target.path} - {trail} - {description} - class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]" - > - {#snippet titleBadge()} - - {#if version != undefined && !refused} - - v{version} - - {/if} - {/snippet} - {#snippet levelBadge()} - - {#if inEvals && !evalsLocation} - Beta - {/if} - {/snippet} - - {#snippet settings()} -
- {#if !inEvals && !refused} - - {#if testPane?.mode} - testPane?.mode, - (mode) => { - if (testPane) testPane.mode = mode - } - } - noWFull - > - {#snippet children({ item })} - - - {/snippet} - - {/if} - {#if readOnly} - - Read only - - {/if} - - {#if !draftOnly} - - {/if} - - {/if} -
- {/snippet} + {#if layout === 'modal'} + true, (open) => !open && close()} + kind="X" + fillHeight + enterConfirms={false} + paginated + title={shownPath ?? target.path} + {trail} + {description} + {titleBadge} + {levelBadge} + {settings} + class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]" + > + {@render body()} + + {:else}
- -
- draft?.deployed} - getCurrent={() => draft?.state} - onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')} - title="Deployed <> Unsaved agent changes" - /> +
+
+
+ {#each trail as segment, i (i)} + {#if i > 0} + + {/if} + {#if segment.onclick} + + {:else} + {segment.label} + {/if} + {#if i === 0} + {@render titleBadge()} + {/if} + {/each} + {@render levelBadge()} +
+ {#if description} + {description} + {/if} +
+
+ {@render settings()} +
+
+ {@render body()}
- -
- + {/if} {/key} + {#snippet titleBadge()} + + {#if version != undefined && !refused} + + v{version} + + {/if} + {/snippet} + {#snippet levelBadge()} + + {#if inEvals && !evalsLocation} + Beta + {/if} + {/snippet} + + {#snippet settings()} +
+ {#if !inEvals && !refused} + + {#if testPane?.mode && !inSettings} + testPane?.mode, + (mode) => { + if (testPane) testPane.mode = mode + } + } + noWFull + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {#if readOnly} + + Read only + + {/if} + {#if !inSettings} + + {/if} + + {/if} +
+ {/snippet} + {#snippet body()} +
+ +
+ draft?.deployed} + getCurrent={() => draft?.state} + onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')} + title="Deployed <> Unsaved agent changes" + /> +
+ + +
+ {/snippet} + + and the config a draft run is offered on. The target is read optionally: a deploy that + navigates away clears it while these are still rendered. --> {#snippet agentPage()} showAgentEditorTool(id)} {onSaved} + isNew={target?.isNew} /> {/snippet} + {#snippet settingsFields()} + {#if draft} + host?.pathError(), (error) => host?.setPathError(error)} + /> + {/if} + {/snippet} + + {#snippet settingsPage()} +
{@render settingsFields()}
+ {/snippet} + {#snippet evalsPage()} {/snippet} + {#if layout === 'page' && ws} + + {/if} + + + settingsDrawer?.closeDrawer()}> + {@render settingsFields()} + + + versionDrawer?.closeDrawer()} noPadding> + import { Badge } from '$lib/components/common' + import Modal from '$lib/components/common/modal/Modal.svelte' + import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte' + import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils' + import type { AgentDraft } from '$lib/gen' + + /** An agent's evals in a dialog of their own, over the page that opened them. */ + interface Props { + agentPath: string + workspace: string + /** The unsaved edits, offered to a run as an alternative to the deployed version. */ + editedConfig?: () => AgentDraft + } + + let { agentPath, workspace, editedConfig = undefined }: Props = $props() + + let open = $state(false) + // Where the evals pane is within itself, so its levels extend the dialog's trail. Cleared on the + // way in: the pane reports a level once it is on one, and never that it is back at its root. + let location = $state(undefined) + + export function openModal() { + location = undefined + open = true + } + + +{#if open} + + {#snippet titleBadge()} + {#if !location} + Beta + {/if} + {/snippet} + + +{/if} diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index 83be7c876e..dc7c0948eb 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -8,6 +8,8 @@ import ResourcePathHint from '$lib/components/ResourcePathHint.svelte' import { ResourceService, type InputTransform, type Resource } from '$lib/gen' import { sendUserToast } from '$lib/toast' + import { userStore } from '$lib/stores' + import { canWrite } from '$lib/utils' import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte' import { AGENT_BRAIN_KEYS, @@ -116,6 +118,8 @@ fromDraft: boolean providerPath?: string providerOk: boolean + /** The agent's own sharing, to tell whether this user may edit it. */ + extraPerms?: Record /** The link cannot be read. `missing` (404): nothing exists at the path, the agent having been * renamed or deleted. `forbidden` (401/403): it exists and this user is refused it, a folder * they cannot read included, which says nothing about whether a run of the flow can read it. @@ -197,7 +201,8 @@ tools, fromDraft: draft != undefined, providerPath, - providerOk + providerOk, + extraPerms: (response.extra_perms ?? {}) as Record } } ) @@ -228,6 +233,11 @@ let providerPath = $derived(linkedInfo?.providerPath) let providerOk = $derived(linkedInfo?.providerOk ?? true) let unavailable = $derived(linkedInfo?.unavailable ?? false) + let canEditAgent = $derived( + !!agent && + !$userStore?.operator && + canWrite(agent, linkedInfo?.extraPerms ?? {}, $userStore ?? undefined) + ) // The hint flips on the first keystroke in the agent editor, so the badge does not wait for the // debounced autosave and the refetch behind it; the fetched answer covers a draft written // elsewhere, which no editor here has published an opinion about. @@ -678,10 +688,19 @@
{:else if !providerOk}
- - This agent's model provider{#if providerPath} - ({providerPath}){/if} isn't accessible in this workspace. - Unlink to fork the agent, or gain access to the provider resource. + + + {#if providerPath} + You don't have access to {providerPath}. + {:else} + You don't have access to this agent's model provider. + {/if} + {#if canEditAgent && !fromAgentEditor} + Edit the agent to use another, unlink it, or ask for access. + {:else} + Unlink it or ask for access. + {/if}
{/if} diff --git a/frontend/src/lib/components/flows/content/AgentSettings.svelte b/frontend/src/lib/components/flows/content/AgentSettings.svelte new file mode 100644 index 0000000000..449edfccff --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentSettings.svelte @@ -0,0 +1,79 @@ + + +{#if draft.state} +
+ + {#if readOnly} + {#if draft.state.labels?.length} +
+ {#each draft.state.labels as label (label)} + {label} + {/each} +
+ {/if} + {:else} + + {/if} + {#if deployTo.current} + + {/if} + +
+{/if} diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index 04671e061b..321caeb190 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -61,6 +61,8 @@ /** What a message runs, as the chat names it. An agent has no deployed chats, so its * sidebar offers no filter between those and the test ones. */ subject?: 'flow' | 'agent' + /** The host's own controls, after the model's in the composer's footer. */ + composerSettings?: import('svelte').Snippet } let { @@ -75,7 +77,8 @@ wideLayout = false, frame = 'top', conversationKind = 'deployed', - subject = 'flow' + subject = 'flow', + composerSettings = undefined }: Props = $props() const flowEditorContext = getContext('FlowEditorContext') @@ -239,6 +242,7 @@ {wideLayout} {conversationKind} {subject} + extraSettings={composerSettings} /> {/if} diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index 51046cd03d..f6cc9af6f5 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -55,6 +55,8 @@ conversationKind?: 'test' | 'deployed' /** What a message runs, as the composer names it. */ subject?: 'flow' | 'agent' + /** The host's own controls, after the model's in the composer's footer. */ + extraSettings?: import('svelte').Snippet } let { @@ -71,7 +73,8 @@ description = undefined, wideLayout = false, conversationKind = 'deployed', - subject = 'flow' + subject = 'flow', + extraSettings = undefined }: Props = $props() const operatingUser = useOperatingUser() @@ -301,6 +304,7 @@ {workspace} /> {/if} + {@render extraSettings?.()} {/snippet} diff --git a/frontend/src/routes/(root)/(logged)/agents/add/+page.ts b/frontend/src/routes/(root)/(logged)/agents/add/+page.ts new file mode 100644 index 0000000000..38697b7210 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/agents/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('agents/edit') diff --git a/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte new file mode 100644 index 0000000000..b4539d0a8e --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte @@ -0,0 +1,31 @@ + + +
+ t.host === undefined} + onClose={(deployed) => goto(deployed ? `${base}/agents/get/${path}` : `${base}/?kind=agent`)} + onDeployed={(saved) => + // Replaced, not pushed, when this URL stopped naming what it opens: Back would otherwise + // reopen `new_draft` on an agent that now exists, or a path a rename just moved away from. + goto(`${base}/agents/get/${saved}`, { replaceState: isNew || saved !== path })} + /> +
diff --git a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte new file mode 100644 index 0000000000..f6de4c078e --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte @@ -0,0 +1,188 @@ + + + + + (deleteOpen = false)} + on:confirmed={() => { + deleteOpen = false + remove() + }} +> + Every flow that links {path} will fail at its agent step once it is deleted. + + +
+ evalsModal?.openModal() + } + }, + { + label: 'Edit', + buttonProps: { + variant: 'accent', + unifiedSize: 'md', + startIcon: Pen, + href: `${base}/agents/edit/${path}` + } + } + ] + : []) + ]} + > + {#snippet leading_actions()} + + {#if chatAvailable && testPane?.mode} + testPane?.mode, + (mode) => { + if (testPane) testPane.mode = mode + } + } + noWFull + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {/snippet} + +
+ {#key `${ws}:${path}`} + configModal?.open()} + > + {#snippet viewForm({ schema, run, loading, actions })} + + {/snippet} + + {/key} +
+
+ +{#if ws} + +{/if} + +{#if config} + host?.toolSchema(id)} /> +{/if}