mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-15 08:02:53 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c6ece17452
commit
b04de3eeda
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT resource_type = 'ai_agent' FROM resource WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1564e75bd7888dc54baec981bf9d855df12d32b0102c38b15e5f81162a00a66b"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT resource_type, value AS \"value: sqlx::types::Json<Box<RawValue>>\"\n FROM resource WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "resource_type",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "15f53f40a49b538ca0d4456f69b8d801e8c121f1e0cd904a77ce55bcf9bc5053"
|
||||
}
|
||||
+3
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft,\n resource.on_behalf_of\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2",
|
||||
"query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,11 +97,6 @@
|
||||
"ordinal": 18,
|
||||
"name": "is_draft",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "on_behalf_of",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -129,9 +124,8 @@
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1d5c68d62b35386ada241fe84a511e33926d151d1a916dfa6c0959217b0232b3"
|
||||
"hash": "9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE resource SET on_behalf_of = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c6c5e40893aa766cd55af5d65d5a19ac213f3a99656e6efea94d13c9af4d904a"
|
||||
}
|
||||
+2
-8
@@ -1,17 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\", on_behalf_of\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "on_behalf_of",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -21,9 +16,8 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "38a233bd5816deb70773796a682a5bceb415da87b69296f9ac50679392274f2d"
|
||||
"hash": "f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c"
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
DROP TRIGGER IF EXISTS reset_agent_on_behalf_of ON resource;
|
||||
DROP FUNCTION IF EXISTS reset_agent_on_behalf_of();
|
||||
ALTER TABLE resource DROP COLUMN IF EXISTS on_behalf_of;
|
||||
ALTER TABLE resource DISABLE TRIGGER record_resource_version_update_trigger;
|
||||
UPDATE resource SET value = value - 'on_behalf_of'
|
||||
WHERE resource_type = 'ai_agent' AND jsonb_typeof(value) = 'object' AND value ? 'on_behalf_of';
|
||||
ALTER TABLE resource ENABLE TRIGGER record_resource_version_update_trigger;
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
-- The identity an AI agent runs as when someone runs it from its own page, as a flow's or a
|
||||
-- script's `on_behalf_of` is: a principal (`u/<name>`, `g/<group>`), resolved at deploy time.
|
||||
-- Only `ai_agent` resources read it; every other resource type leaves it NULL.
|
||||
ALTER TABLE resource ADD COLUMN IF NOT EXISTS on_behalf_of VARCHAR(255);
|
||||
|
||||
-- An agent's value is code it runs (inline tools, instructions), so a write of it that does not
|
||||
-- say who the agent now runs as must not keep running it as whoever wrote the previous one:
|
||||
-- anyone who can write the value would otherwise act with that account's permissions. The API's
|
||||
-- writers set the column again in the same transaction; any other path (a direct SQL update, a
|
||||
-- writer added later) leaves it cleared, which runs the agent as whoever runs it.
|
||||
CREATE OR REPLACE FUNCTION reset_agent_on_behalf_of() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.resource_type = 'ai_agent'
|
||||
AND NEW.value IS DISTINCT FROM OLD.value
|
||||
AND NEW.on_behalf_of IS NOT DISTINCT FROM OLD.on_behalf_of THEN
|
||||
NEW.on_behalf_of := NULL;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER reset_agent_on_behalf_of
|
||||
BEFORE UPDATE ON resource
|
||||
FOR EACH ROW EXECUTE FUNCTION reset_agent_on_behalf_of();
|
||||
-- An AI agent's value carries the identity it runs as when run from its own page, as a flow's or
|
||||
-- a script's `on_behalf_of` is: a principal (`u/<name>`, `g/<group>`), resolved by the server on
|
||||
-- every write of the agent. An agent saved before this ran as whoever ran it; give it the
|
||||
-- account that wrote its current version, the one a write now records.
|
||||
|
||||
-- Mirrors `users::username_to_permissioned_as`, as the sibling backfills do. `pg_temp` lives for
|
||||
-- the whole session and migrations share one connection, so replace any earlier copy and drop
|
||||
@@ -36,12 +16,14 @@ RETURNS VARCHAR AS $$
|
||||
END;
|
||||
$$ LANGUAGE SQL IMMUTABLE;
|
||||
|
||||
-- An agent saved before this column ran as whoever ran it; give it the account that wrote its
|
||||
-- current version, the one a deploy now records. A principal wider than
|
||||
-- `v2_job.permissioned_as` could not be enqueued, so it is left unset, as the sibling backfills
|
||||
-- leave it (20260801043001, 20260911085221).
|
||||
-- Filling in the identity is not an edit of the agent, so it records no version.
|
||||
ALTER TABLE resource DISABLE TRIGGER record_resource_version_update_trigger;
|
||||
|
||||
-- A principal wider than `v2_job.permissioned_as` could not be enqueued, so it is left unset, as
|
||||
-- the sibling backfills leave it (20260801043001, 20260911085221).
|
||||
UPDATE resource r
|
||||
SET on_behalf_of = pg_temp.username_to_permissioned_as(v.created_by)
|
||||
SET value = jsonb_set(r.value, '{on_behalf_of}',
|
||||
to_jsonb(pg_temp.username_to_permissioned_as(v.created_by)))
|
||||
FROM (
|
||||
SELECT DISTINCT ON (workspace_id, path) workspace_id, path, created_by
|
||||
FROM resource_version
|
||||
@@ -50,9 +32,12 @@ UPDATE resource r
|
||||
WHERE r.workspace_id = v.workspace_id
|
||||
AND r.path = v.path
|
||||
AND r.resource_type = 'ai_agent'
|
||||
AND r.on_behalf_of IS NULL
|
||||
AND jsonb_typeof(r.value) = 'object'
|
||||
AND NOT (r.value ? 'on_behalf_of')
|
||||
AND v.created_by IS NOT NULL
|
||||
AND v.created_by <> ''
|
||||
AND length(pg_temp.username_to_permissioned_as(v.created_by)) <= 55;
|
||||
|
||||
ALTER TABLE resource ENABLE TRIGGER record_resource_version_update_trigger;
|
||||
|
||||
DROP FUNCTION pg_temp.username_to_permissioned_as(VARCHAR);
|
||||
|
||||
@@ -31606,12 +31606,9 @@ components:
|
||||
type: string
|
||||
ws_specific:
|
||||
type: boolean
|
||||
on_behalf_of:
|
||||
type: string
|
||||
description: ai_agent only. The identity the agent runs as (`u/<username>`, `g/<group>`), kept only with preserve_on_behalf_of by a caller allowed to; otherwise the agent runs as the caller.
|
||||
preserve_on_behalf_of:
|
||||
type: boolean
|
||||
description: ai_agent only. Keep on_behalf_of rather than running as the caller. Honoured for admins and members of wm_deployers.
|
||||
description: ai_agent only. Keep the identity `value.on_behalf_of` names (`u/<username>`, `g/<group>`) rather than the caller's own. Honoured for admins and members of wm_deployers; otherwise every write of an agent sets it to the caller.
|
||||
required:
|
||||
- path
|
||||
- value
|
||||
@@ -31636,12 +31633,9 @@ components:
|
||||
type: string
|
||||
ws_specific:
|
||||
type: boolean
|
||||
on_behalf_of:
|
||||
type: string
|
||||
description: ai_agent only. The identity the agent runs as (`u/<username>`, `g/<group>`), kept only with preserve_on_behalf_of by a caller allowed to; a new value otherwise makes the agent run as the caller.
|
||||
preserve_on_behalf_of:
|
||||
type: boolean
|
||||
description: ai_agent only. Keep on_behalf_of rather than running as the caller. Honoured for admins and members of wm_deployers.
|
||||
description: ai_agent only. Keep the identity `value.on_behalf_of` names (`u/<username>`, `g/<group>`) rather than the caller's own. Honoured for admins and members of wm_deployers; otherwise every write of an agent sets it to the caller.
|
||||
|
||||
Resource:
|
||||
type: object
|
||||
@@ -31762,9 +31756,6 @@ components:
|
||||
(over a deployed row or a synthesized draft-only row).
|
||||
Frontend appends a `*` to the displayed name.
|
||||
type: boolean
|
||||
on_behalf_of:
|
||||
type: string
|
||||
description: ai_agent only. The identity the agent runs as when run from its own page.
|
||||
agent_memory:
|
||||
description: |
|
||||
For an `ai_agent` resource only, its `memory` setting: the one part
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 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 agent's
|
||||
//! `on_behalf_of`, resolved when the agent was deployed.
|
||||
//! `on_behalf_of`, which the server resolves on every write of the agent.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -46,8 +46,8 @@ pub(crate) async fn run_agent(
|
||||
|
||||
// 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 row = sqlx::query!(
|
||||
"SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\", on_behalf_of
|
||||
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
|
||||
@@ -55,13 +55,17 @@ pub(crate) async fn run_agent(
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
let row = not_found_if_none(row, "Agent", path)?;
|
||||
let Some(value) = row.value else {
|
||||
let Some(sqlx::types::Json(mut 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.0)?;
|
||||
// Who the agent runs as, not one of its inputs.
|
||||
let agent_obo = value
|
||||
.as_object_mut()
|
||||
.and_then(|o| o.remove("on_behalf_of"))
|
||||
.and_then(|v| v.as_str().map(str::to_string));
|
||||
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.
|
||||
@@ -74,7 +78,7 @@ pub(crate) async fn run_agent(
|
||||
let flow_value = agent_flow(&config, chat)?;
|
||||
|
||||
let on_behalf_of =
|
||||
windmill_common::on_behalf_of_from_permissioned_as(row.on_behalf_of.as_deref(), &w_id, &db)
|
||||
windmill_common::on_behalf_of_from_permissioned_as(agent_obo.as_deref(), &w_id, &db)
|
||||
.await?;
|
||||
// As `run_flow` picks the identity of a deployed flow: the agent's own when it has one, the
|
||||
// caller's otherwise, which lends nobody's permissions.
|
||||
|
||||
@@ -933,7 +933,14 @@ pub(crate) async fn tarball_workspace(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for resource in resources {
|
||||
for mut resource in resources {
|
||||
// An agent's identity belongs to this workspace, and whoever writes it elsewhere gets
|
||||
// their own, as `to_string_without_metadata` leaves out a flow's.
|
||||
if resource.resource_type == "ai_agent" {
|
||||
if let Some(obj) = resource.value.as_mut().and_then(|v| v.as_object_mut()) {
|
||||
obj.remove("on_behalf_of");
|
||||
}
|
||||
}
|
||||
let resource_str =
|
||||
&to_string_without_metadata(&resource, ExtraPermsBehavior::Drop, None).unwrap();
|
||||
archive
|
||||
|
||||
@@ -204,10 +204,6 @@ pub struct ListableResource {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[sqlx(default)]
|
||||
pub is_draft: Option<bool>,
|
||||
/// `ai_agent` only: the identity it runs as when run from its own page.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[sqlx(default)]
|
||||
pub on_behalf_of: Option<String>,
|
||||
}
|
||||
|
||||
/// A row of the resource listing: the resource as a single read returns it, plus what only the
|
||||
@@ -238,10 +234,8 @@ pub struct CreateResource {
|
||||
pub labels: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub ws_specific: Option<bool>,
|
||||
/// `ai_agent` only: the identity to keep, honoured with `preserve_on_behalf_of` for a caller
|
||||
/// allowed to keep one (see `set_agent_on_behalf_of`).
|
||||
#[serde(default)]
|
||||
pub on_behalf_of: Option<String>,
|
||||
/// `ai_agent` only: keep the identity `value.on_behalf_of` names, for a caller allowed to
|
||||
/// (see `resolve_agent_on_behalf_of`).
|
||||
#[serde(default)]
|
||||
pub preserve_on_behalf_of: Option<bool>,
|
||||
}
|
||||
@@ -253,7 +247,6 @@ struct EditResource {
|
||||
resource_type: Option<String>,
|
||||
labels: Option<Vec<String>>,
|
||||
ws_specific: Option<bool>,
|
||||
on_behalf_of: Option<String>,
|
||||
preserve_on_behalf_of: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -569,8 +562,6 @@ async fn list_resources(
|
||||
draft_only: Some(true),
|
||||
// Synthesized rows are the authed user's draft.
|
||||
is_draft: Some(true),
|
||||
// Resolved on the first deploy; a draft runs nowhere but the editor's preview.
|
||||
on_behalf_of: None,
|
||||
};
|
||||
rows.push(ListedResource { resource, agent_memory, draft_path });
|
||||
}
|
||||
@@ -604,8 +595,7 @@ async fn get_resource(
|
||||
variable.account,
|
||||
ws_specific.path IS NOT NULL as ws_specific,
|
||||
null::bool as draft_only,
|
||||
null::bool as is_draft,
|
||||
resource.on_behalf_of
|
||||
null::bool as is_draft
|
||||
FROM resource
|
||||
LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2
|
||||
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2
|
||||
@@ -1228,7 +1218,20 @@ async fn create_resource(
|
||||
check_path_conflict(&mut tx, &w_id, &resource.path).await?;
|
||||
}
|
||||
|
||||
let res_value = resource.value.unwrap_or_default();
|
||||
let mut res_value = resource.value.unwrap_or_default();
|
||||
if resource.resource_type == "ai_agent" {
|
||||
res_value = resolve_agent_raw_value(
|
||||
&mut tx,
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
&resource.path,
|
||||
&res_value,
|
||||
resource.preserve_on_behalf_of.unwrap_or(false),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let raw_json = sqlx::types::Json(res_value.as_ref());
|
||||
|
||||
if resource.path.starts_with("f/app_themes/") {
|
||||
@@ -1301,20 +1304,6 @@ async fn create_resource(
|
||||
}
|
||||
}
|
||||
|
||||
if resource.resource_type == "ai_agent" {
|
||||
set_agent_on_behalf_of(
|
||||
&mut tx,
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
&resource.path,
|
||||
resource.on_behalf_of.as_deref(),
|
||||
resource.preserve_on_behalf_of.unwrap_or(false),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Mirror update_resource: Some(true) inserts, Some(false) clears (only
|
||||
// meaningful on the upsert path, since a pure create has no existing row),
|
||||
// None leaves the existing flag alone.
|
||||
@@ -2083,9 +2072,6 @@ async fn update_resource(
|
||||
if let Some(npath) = &ns.path {
|
||||
sqlb.set_str("path", npath);
|
||||
}
|
||||
if let Some(nvalue) = &ns.value {
|
||||
sqlb.set_str("value", nvalue.to_string());
|
||||
}
|
||||
if let Some(nrt) = &ns.resource_type {
|
||||
sqlb.set_str("resource_type", nrt);
|
||||
}
|
||||
@@ -2193,6 +2179,48 @@ async fn update_resource(
|
||||
}
|
||||
}
|
||||
|
||||
let current = sqlx::query!(
|
||||
"SELECT resource_type, value AS \"value: sqlx::types::Json<Box<RawValue>>\"
|
||||
FROM resource WHERE workspace_id = $1 AND path = $2",
|
||||
&w_id,
|
||||
path
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let resource_type = ns
|
||||
.resource_type
|
||||
.clone()
|
||||
.or_else(|| current.as_ref().map(|c| c.resource_type.clone()));
|
||||
// Retyping a resource into an agent makes its value an agent's, identity included, so it is
|
||||
// resolved as a written one is.
|
||||
let retyped = ns.resource_type.as_deref() == Some("ai_agent")
|
||||
&& current
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.resource_type != "ai_agent");
|
||||
let nvalue = match &ns.value {
|
||||
Some(v) => Some(v.clone()),
|
||||
None if retyped => current.and_then(|c| c.value).map(|v| v.0),
|
||||
None => None,
|
||||
};
|
||||
if let Some(nvalue) = nvalue {
|
||||
let nvalue = if resource_type.as_deref() == Some("ai_agent") {
|
||||
resolve_agent_raw_value(
|
||||
&mut tx,
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
ns.path.as_deref().unwrap_or(path),
|
||||
&nvalue,
|
||||
ns.preserve_on_behalf_of.unwrap_or(false),
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
nvalue
|
||||
};
|
||||
sqlb.set_str("value", nvalue.to_string());
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let npath_o: Option<String> = sqlx::query_scalar(&sql)
|
||||
.fetch_optional(&mut *tx)
|
||||
@@ -2201,33 +2229,6 @@ async fn update_resource(
|
||||
|
||||
let npath = not_found_if_none(npath_o, "Resource", path)?;
|
||||
|
||||
// A description or label edit changes nothing the agent runs, so only a new value or an
|
||||
// identity asked for re-resolves who it runs as.
|
||||
if ns.value.is_some() || ns.on_behalf_of.is_some() {
|
||||
let is_agent = sqlx::query_scalar!(
|
||||
"SELECT resource_type = 'ai_agent' FROM resource WHERE workspace_id = $1 AND path = $2",
|
||||
&w_id,
|
||||
&npath
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
if is_agent {
|
||||
set_agent_on_behalf_of(
|
||||
&mut tx,
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
&npath,
|
||||
ns.on_behalf_of.as_deref(),
|
||||
ns.preserve_on_behalf_of.unwrap_or(false),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(nlabels) = &ns.labels {
|
||||
sqlx::query!(
|
||||
"UPDATE resource SET labels = $1 WHERE path = $2 AND workspace_id = $3",
|
||||
@@ -2389,21 +2390,27 @@ async fn update_resource_value(
|
||||
Ok(format!("value of resource {} updated", path))
|
||||
}
|
||||
|
||||
/// Records the identity an `ai_agent` runs as, resolved on every write of it as a flow's
|
||||
/// `on_behalf_of` is on every deploy: the writer's own, unless one allowed to keep another asks
|
||||
/// to, and a folder's default for a new agent. Called in the writer's transaction after the
|
||||
/// value lands, since a value change clears the column (`reset_agent_on_behalf_of`).
|
||||
async fn set_agent_on_behalf_of(
|
||||
/// Rewrites the identity an `ai_agent` runs as (`value.on_behalf_of`), resolved on every write of
|
||||
/// it as a flow's `on_behalf_of` is on every deploy: the writer's own, unless one allowed to keep
|
||||
/// the one the value names asks to, and a folder's default for a new agent. The value is written
|
||||
/// by anyone who can write the agent, so the key it arrives with is only a request.
|
||||
async fn resolve_agent_on_behalf_of(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
authed: &ApiAuthed,
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
requested: Option<&str>,
|
||||
value: &mut serde_json::Value,
|
||||
preserve: bool,
|
||||
created: bool,
|
||||
) -> Result<()> {
|
||||
let (mut requested, mut preserve) = (requested.map(str::to_string), preserve);
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut requested = obj
|
||||
.remove("on_behalf_of")
|
||||
.and_then(|v| v.as_str().map(str::to_string));
|
||||
let mut preserve = preserve;
|
||||
let explicit =
|
||||
requested.is_some() && preserve && windmill_common::can_preserve_on_behalf_of(authed);
|
||||
if created && !explicit && windmill_common::can_preserve_on_behalf_of(authed) {
|
||||
@@ -2425,14 +2432,9 @@ async fn set_agent_on_behalf_of(
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE resource SET on_behalf_of = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
resolved,
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if let Some(resolved) = resolved.as_ref() {
|
||||
obj.insert("on_behalf_of".to_string(), resolved.clone().into());
|
||||
}
|
||||
if let Some(kept) = windmill_common::check_on_behalf_of_preservation(
|
||||
resolved.as_deref(),
|
||||
preserve,
|
||||
@@ -2457,6 +2459,22 @@ async fn set_agent_on_behalf_of(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `resolve_agent_on_behalf_of` on a value as the request carried it.
|
||||
async fn resolve_agent_raw_value(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
authed: &ApiAuthed,
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
value: &RawValue,
|
||||
preserve: bool,
|
||||
created: bool,
|
||||
) -> Result<Box<RawValue>> {
|
||||
let mut parsed: serde_json::Value = serde_json::from_str(value.get())?;
|
||||
resolve_agent_on_behalf_of(tx, authed, db, w_id, path, &mut parsed, preserve, created).await?;
|
||||
Ok(serde_json::value::to_raw_value(&parsed)?)
|
||||
}
|
||||
|
||||
/// Write a resource's value and run everything that has to follow it: a version row, the audit
|
||||
/// entry, deployment metadata, the webhook, and dependent CI tests. Shared with version restore
|
||||
/// so a restored value is indistinguishable downstream from any other edit.
|
||||
@@ -2485,6 +2503,20 @@ async fn set_resource_value(
|
||||
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
|
||||
let mut value = value;
|
||||
if let Some(v) = value.as_mut() {
|
||||
let resource_type = sqlx::query_scalar!(
|
||||
"SELECT resource_type FROM resource WHERE workspace_id = $1 AND path = $2",
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if resource_type.as_deref() == Some("ai_agent") {
|
||||
resolve_agent_on_behalf_of(&mut tx, authed, db, w_id, path, v, false, false).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// `RETURNING resource_type` rather than a second lookup: the advisory below has to know the
|
||||
// type to leave `state` and `cache` alone, and this statement already runs.
|
||||
let updated = sqlx::query_scalar!(
|
||||
@@ -2499,9 +2531,6 @@ async fn set_resource_value(
|
||||
let Some(resource_type) = updated else {
|
||||
return Err(Error::NotFound(format!("Resource {} not found", path)));
|
||||
};
|
||||
if resource_type == "ai_agent" {
|
||||
set_agent_on_behalf_of(&mut tx, authed, db, w_id, path, None, false, false).await?;
|
||||
}
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
authed,
|
||||
|
||||
@@ -20,6 +20,11 @@ import { Resource } from "../../../gen/types.gen.ts";
|
||||
import { readInlinePathSync, readTextFile } from "../../utils/utils.ts";
|
||||
import { isWorkspaceSpecificFile } from "../../core/specific_items.ts";
|
||||
import { getCurrentGitBranch } from "../../utils/git.ts";
|
||||
import {
|
||||
buildPermissionedAsContext,
|
||||
type PermissionedAsContext,
|
||||
} from "../../core/permissioned_as.ts";
|
||||
import { readEffectiveSyncBehavior } from "../../core/conf.ts";
|
||||
|
||||
export interface ResourceFile {
|
||||
value: any;
|
||||
@@ -83,6 +88,7 @@ export async function pushResource(
|
||||
// only round-trip the canonical layout); the standalone `resource push`
|
||||
// command pushes a single explicit file, where any pointer is fine.
|
||||
enforceCanonicalFileset?: boolean,
|
||||
permissionedAsContext?: PermissionedAsContext,
|
||||
): Promise<void> {
|
||||
remotePath = removeType(remotePath, "resource");
|
||||
try {
|
||||
@@ -93,6 +99,11 @@ export async function pushResource(
|
||||
} catch {
|
||||
// flow doesn't exist
|
||||
}
|
||||
const claimed = agentOnBehalfOf(
|
||||
resource,
|
||||
localResource,
|
||||
permissionedAsContext
|
||||
);
|
||||
|
||||
// Helper function to resolve inline content
|
||||
const resolveInlineContent = async () => {
|
||||
@@ -133,11 +144,23 @@ export async function pushResource(
|
||||
|
||||
// Only resolve inline content if we're actually updating
|
||||
await resolveInlineContent();
|
||||
if (claimed) {
|
||||
log.info(`Preserving ${claimed} as permissioned_as for agent ${remotePath}`);
|
||||
}
|
||||
|
||||
await wmill.updateResource({
|
||||
workspace: workspace,
|
||||
path: remotePath.replaceAll(SEP, "/"),
|
||||
requestBody: { ...localResource, ...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}) },
|
||||
requestBody: {
|
||||
...localResource,
|
||||
...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}),
|
||||
...(claimed
|
||||
? {
|
||||
value: { ...localResource.value, on_behalf_of: claimed },
|
||||
preserve_on_behalf_of: true,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// New resource - resolve inline content
|
||||
@@ -163,6 +186,39 @@ export async function pushResource(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent runs as the identity in its value's `on_behalf_of`, which the backend
|
||||
* sets to whoever writes the agent. Like an app's, it is never taken from the
|
||||
* tracked file, which the pull leaves it out of: the deployed one is claimed
|
||||
* back only by a pusher the backend lets keep it, an admin or a `wm_deployers`
|
||||
* member. Strips the key from `localResource` (and from the deployed value, so
|
||||
* an unchanged agent still compares equal) and returns the identity to claim.
|
||||
*/
|
||||
export function agentOnBehalfOf(
|
||||
deployed: ResourceFile | Resource | undefined,
|
||||
localResource: ResourceFile,
|
||||
permissionedAsContext: PermissionedAsContext | undefined
|
||||
): string | undefined {
|
||||
if ((deployed?.resource_type ?? localResource.resource_type) !== "ai_agent") {
|
||||
return undefined;
|
||||
}
|
||||
const local = localResource.value;
|
||||
if (local && typeof local === "object") {
|
||||
delete local.on_behalf_of;
|
||||
}
|
||||
const value = deployed?.value;
|
||||
const onBehalfOf =
|
||||
value && typeof value === "object" ? value.on_behalf_of : undefined;
|
||||
if (deployed && onBehalfOf !== undefined) {
|
||||
const { on_behalf_of: _, ...rest } = value;
|
||||
deployed.value = rest;
|
||||
}
|
||||
if (!onBehalfOf || !permissionedAsContext?.userIsAdminOrDeployer) {
|
||||
return undefined;
|
||||
}
|
||||
return onBehalfOf;
|
||||
}
|
||||
|
||||
type PushOptions = GlobalOptions;
|
||||
async function push(opts: PushOptions, filePath: string, remotePath: string) {
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
@@ -184,7 +240,13 @@ async function push(opts: PushOptions, filePath: string, remotePath: string) {
|
||||
remotePath,
|
||||
undefined,
|
||||
parseFromFile(filePath),
|
||||
filePath // Pass the local file path for branch-specific inline content resolution
|
||||
filePath, // Pass the local file path for branch-specific inline content resolution
|
||||
undefined,
|
||||
undefined,
|
||||
await buildPermissionedAsContext(
|
||||
workspace.workspaceId,
|
||||
await readEffectiveSyncBehavior(opts, workspace)
|
||||
)
|
||||
);
|
||||
log.info(colors.bold.underline.green(`Resource ${remotePath} pushed`));
|
||||
}
|
||||
|
||||
@@ -5918,6 +5918,7 @@ export async function push(
|
||||
resourceFilePath,
|
||||
isFileResWsSpecific ? true : undefined,
|
||||
true,
|
||||
permissionedAsContext,
|
||||
);
|
||||
}
|
||||
// Already-synced parents got the full content this run.
|
||||
|
||||
@@ -246,6 +246,15 @@ export async function preCheckPermissionedAs(
|
||||
if (match) {
|
||||
currentOwner = match[1];
|
||||
}
|
||||
} else if (typeStr === "resource") {
|
||||
// An agent always runs as someone, and the file never says who.
|
||||
if (/["']?resource_type["']?\s*:\s*["']?ai_agent\b/.test(beforeContent)) {
|
||||
wouldChangeItems.push({
|
||||
path: change.path,
|
||||
currentOwner: "(agent owner)",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
} else if (typeStr.endsWith("_trigger")) {
|
||||
wouldChangeItems.push({
|
||||
path: change.path,
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ export async function pushObj(
|
||||
} else if (typeEnding === "resource") {
|
||||
if (!alreadySynced.includes(p)) {
|
||||
alreadySynced.push(p);
|
||||
await pushResource(workspace, p, befObj, newObj, originalLocalPath || p, wsSpecific, true);
|
||||
await pushResource(workspace, p, befObj, newObj, originalLocalPath || p, wsSpecific, true, permissionedAsContext);
|
||||
}
|
||||
} else if (typeEnding === "resource-type") {
|
||||
await pushResourceType(workspace, p, befObj, newObj);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* An agent's run-as identity lives in its value, which the pull leaves out. A
|
||||
* push must neither see that gap as a change nor let the file pick who the
|
||||
* agent runs as: only the deployed identity is claimed back, and only by a
|
||||
* pusher the backend lets keep it.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { isSuperset } from "../src/types.ts";
|
||||
import { agentOnBehalfOf } from "../src/commands/resource/resource.ts";
|
||||
|
||||
const ctx = (userIsAdminOrDeployer: boolean) => ({
|
||||
userCache: new Map(),
|
||||
userIsAdminOrDeployer,
|
||||
userEmail: "me@x.dev",
|
||||
});
|
||||
|
||||
function agents(localObo?: string) {
|
||||
const deployed = {
|
||||
resource_type: "ai_agent",
|
||||
value: { system_prompt: "hi", on_behalf_of: "u/bob" },
|
||||
} as any;
|
||||
const local = {
|
||||
resource_type: "ai_agent",
|
||||
value: { system_prompt: "hi", ...(localObo ? { on_behalf_of: localObo } : {}) },
|
||||
} as any;
|
||||
return { deployed, local };
|
||||
}
|
||||
|
||||
test("an unchanged agent still compares equal to the deployed one", () => {
|
||||
const { deployed, local } = agents();
|
||||
agentOnBehalfOf(deployed, local, ctx(true));
|
||||
expect(isSuperset(local, deployed)).toBe(true);
|
||||
});
|
||||
|
||||
test("an admin or deployer claims the deployed identity, never the file's", () => {
|
||||
const { deployed, local } = agents("u/admin");
|
||||
expect(agentOnBehalfOf(deployed, local, ctx(true))).toBe("u/bob");
|
||||
expect(local.value.on_behalf_of).toBeUndefined();
|
||||
});
|
||||
|
||||
test("anyone else claims nothing", () => {
|
||||
const { deployed, local } = agents("u/bob");
|
||||
expect(agentOnBehalfOf(deployed, local, ctx(false))).toBeUndefined();
|
||||
expect(agentOnBehalfOf(agents().deployed, agents().local, undefined)).toBeUndefined();
|
||||
expect(local.value.on_behalf_of).toBeUndefined();
|
||||
});
|
||||
|
||||
test("other resources are left alone", () => {
|
||||
const deployed = { resource_type: "json", value: { on_behalf_of: "u/bob" } } as any;
|
||||
const local = { resource_type: "json", value: { on_behalf_of: "u/x" } } as any;
|
||||
expect(agentOnBehalfOf(deployed, local, ctx(true))).toBeUndefined();
|
||||
expect(local.value.on_behalf_of).toBe("u/x");
|
||||
expect(deployed.value.on_behalf_of).toBe("u/bob");
|
||||
});
|
||||
@@ -144,18 +144,29 @@ export function agentDraftDeployRefusal(
|
||||
*/
|
||||
type AgentWriteResult = { ok: true } | { ok: false; error: string; notAnAgent?: true }
|
||||
|
||||
/** Who a deploy makes the agent run as. The server resolves it as it does a flow's: without
|
||||
* `preserve`, or from someone not allowed to keep another identity, it is the deployer. */
|
||||
export interface AgentRunAs {
|
||||
permissionedAs: string | undefined
|
||||
preserve: boolean
|
||||
}
|
||||
|
||||
async function writeAgentResource(
|
||||
workspace: string,
|
||||
fromPath: string,
|
||||
state: AgentResourceState,
|
||||
noDeployed: boolean
|
||||
noDeployed: boolean,
|
||||
runAs?: AgentRunAs
|
||||
): Promise<AgentWriteResult> {
|
||||
const body = {
|
||||
path: state.path,
|
||||
value: state.args,
|
||||
value: runAs?.permissionedAs
|
||||
? { ...state.args, on_behalf_of: runAs.permissionedAs }
|
||||
: state.args,
|
||||
description: state.description,
|
||||
labels: state.labels,
|
||||
ws_specific: state.wsSpecific
|
||||
ws_specific: state.wsSpecific,
|
||||
preserve_on_behalf_of: runAs?.preserve || undefined
|
||||
}
|
||||
try {
|
||||
if (noDeployed) {
|
||||
@@ -221,9 +232,12 @@ export interface AgentDraftHandle {
|
||||
/** Why this path cannot be edited here, if it cannot. Render it instead of the form. */
|
||||
readonly refusal: string | undefined
|
||||
readonly sync: TriggerDraftSync
|
||||
/** Who the deployed agent runs as when run from its page; undefined when nothing is deployed
|
||||
* or it runs as whoever runs it. */
|
||||
readonly onBehalfOf: string | undefined
|
||||
/** Write the current state to the resource and drop the draft. Resolves to the path written,
|
||||
* which differs from the one loaded when the draft renames the agent, or undefined on failure. */
|
||||
deploy: () => Promise<string | undefined>
|
||||
deploy: (runAs?: AgentRunAs) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,6 +251,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
let loading = $state(true)
|
||||
let noDeployed = $state(false)
|
||||
let canWriteResource = $state(true)
|
||||
let onBehalfOf = $state<string | undefined>(undefined)
|
||||
/** Guards the load against a path that changed under a slow response. */
|
||||
let loadedFor = $state<string | undefined>(undefined)
|
||||
/** Why the loaded path cannot be edited here, if it cannot. Gates the sync for as long as that
|
||||
@@ -324,10 +339,15 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
return
|
||||
}
|
||||
noDeployed = Boolean((r as any).no_deployed)
|
||||
// Who it runs as is the deploy's to decide, not a setting the form or a draft holds.
|
||||
const { on_behalf_of, ...args } = (r.value ?? {}) as AIAgentConfig & {
|
||||
on_behalf_of?: string
|
||||
}
|
||||
onBehalfOf = noDeployed ? undefined : on_behalf_of
|
||||
const deployedState: AgentResourceState = {
|
||||
path: r.path,
|
||||
description: r.description ?? '',
|
||||
args: (r.value ?? {}) as AIAgentConfig,
|
||||
args,
|
||||
// Only where nothing else answers for the type: with a deployed row both the
|
||||
// create below and the review page's `deployDraft` read it from there, and
|
||||
// carrying it would make every draft the generic resource editor writes — which
|
||||
@@ -408,7 +428,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
})
|
||||
})
|
||||
|
||||
async function deploy(): Promise<string | undefined> {
|
||||
async function deploy(runAs?: AgentRunAs): Promise<string | undefined> {
|
||||
const ws = opts.workspace()
|
||||
const fromPath = opts.path()
|
||||
const s = state
|
||||
@@ -424,7 +444,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
// made during the request as saved, and the banner would clear on a value the server never
|
||||
// received; against the snapshot it stays a draft, which is what it is.
|
||||
const submitted = structuredClone($state.snapshot(s)) as AgentResourceState
|
||||
const written = await writeAgentResource(ws, fromPath, submitted, noDeployed)
|
||||
const written = await writeAgentResource(ws, fromPath, submitted, noDeployed, runAs)
|
||||
if (!written.ok) {
|
||||
// A path that is no longer an agent tears this editor down; anything else is a plain error
|
||||
// the user can retry from the form as it stands.
|
||||
@@ -440,6 +460,10 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
logReusableAgentUsage(noDeployed ? 'saved' : 'updated')
|
||||
deployed = submitted
|
||||
noDeployed = false
|
||||
// What the server resolved, which a request to keep an identity does not decide alone.
|
||||
void ResourceService.getResource({ workspace: ws, path: submitted.path })
|
||||
.then((r) => (onBehalfOf = (r.value as { on_behalf_of?: string } | undefined)?.on_behalf_of))
|
||||
.catch(() => {})
|
||||
const renamed = submitted.path !== fromPath
|
||||
// Only when the form still holds exactly what was sent. `discard` resets the handle's cell to
|
||||
// what it is given, and the apply-effect copies that back over the form: against an edit made
|
||||
@@ -479,6 +503,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
get refusal() {
|
||||
return refusal
|
||||
},
|
||||
get onBehalfOf() {
|
||||
return onBehalfOf
|
||||
},
|
||||
sync,
|
||||
deploy
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
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'
|
||||
@@ -50,7 +51,8 @@
|
||||
keepsManagedMemory
|
||||
} from '../agentFormFields'
|
||||
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
|
||||
import { useAgentDraft } from '../agentDraft.svelte'
|
||||
import { useAgentDraft, type AgentRunAs } from '../agentDraft.svelte'
|
||||
import PermissionedAsLine from '$lib/components/triggers/PermissionedAsLine.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -70,8 +72,6 @@
|
||||
/** 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
|
||||
/** Why this reader cannot run the agent, if they cannot. Shown in place of the run pane. */
|
||||
runBlockedReason?: string
|
||||
/** Opens the agent's configuration, offered beside the model it runs with. */
|
||||
onOpenConfig?: () => void
|
||||
}
|
||||
@@ -85,7 +85,6 @@
|
||||
onSaved = undefined,
|
||||
isNew = false,
|
||||
view = false,
|
||||
runBlockedReason = undefined,
|
||||
onOpenConfig = undefined
|
||||
}: Props = $props()
|
||||
|
||||
@@ -351,6 +350,16 @@
|
||||
inputs?: Record<string, any>
|
||||
): Promise<string | undefined> {
|
||||
if (!agentModule) return undefined
|
||||
// A view runs the deployed agent as its on-behalf-of identity, which anyone who can read it
|
||||
// may do; the editor previews the draft as the one editing it.
|
||||
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),
|
||||
@@ -367,15 +376,13 @@
|
||||
let invisible_to_owner = $state<boolean | undefined>(undefined)
|
||||
let overrideTag = $state<string | undefined>(undefined)
|
||||
|
||||
/** One run from the form, as the same one-step flow a chat turn runs but outside any
|
||||
* conversation, then onto its run page. */
|
||||
/** 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 (!agentModule) return
|
||||
if (!workspace) return
|
||||
runLoading = true
|
||||
try {
|
||||
const flow = agentChatFlow($state.snapshot(agentModule) as FlowModule)
|
||||
flow.value.chat_input_enabled = false
|
||||
const id = await runFlowPreview(args, flow, path, undefined, undefined, undefined, workspace)
|
||||
const id = await JobService.runAgent({ workspace, path, requestBody: args })
|
||||
await goto(`/run/${id}?workspace=${workspace}`)
|
||||
} finally {
|
||||
runLoading = false
|
||||
@@ -437,12 +444,15 @@
|
||||
* only report after the request. */
|
||||
let pathError = $state('')
|
||||
|
||||
/** Who the next deploy makes the agent run as, as the "Permissioned as" line picked it. */
|
||||
let runAs = $state<AgentRunAs | undefined>(undefined)
|
||||
|
||||
export function deploy(): Promise<boolean> {
|
||||
if (pathError) {
|
||||
sendUserToast(`Cannot deploy the agent: ${pathError}`, true)
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return draft.deploy().then(async (written) => {
|
||||
return draft.deploy(runAs).then(async (written) => {
|
||||
// The path the write landed on, which a rename moves off the one this editor opened.
|
||||
if (written) await onSaved?.(written)
|
||||
return written !== undefined
|
||||
@@ -558,6 +568,18 @@
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Label>
|
||||
<!-- Who the deployed agent runs as when someone runs it from its page, resolved on
|
||||
deploy as a flow's on-behalf-of identity is. -->
|
||||
{#if !readOnly}
|
||||
<div class="pt-2">
|
||||
<PermissionedAsLine
|
||||
permissionedAs={draft.onBehalfOf}
|
||||
path={draft.state?.path}
|
||||
onPermissionedAsChange={(permissionedAs, preserve) =>
|
||||
(runAs = { permissionedAs, preserve })}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<PropPickerWrapper pickableProperties={stepPropPicker?.pickableProperties} noPadding sidePane>
|
||||
@@ -588,132 +610,121 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet runPane()}
|
||||
{#if runBlockedReason}
|
||||
<div
|
||||
class="h-full flex flex-col items-center justify-center gap-4 px-8 text-center text-xs text-secondary"
|
||||
>
|
||||
{runBlockedReason}
|
||||
{#if onOpenConfig}
|
||||
{@render configurationButton()}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<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
|
||||
<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. -->
|
||||
{#if view}
|
||||
<!-- A run of the deployed agent is a run like any other item's, so it opens on its
|
||||
{#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">
|
||||
<RunForm
|
||||
runnable={{ schema: AGENT_CHAT_SCHEMA, path }}
|
||||
runAction={runOnce}
|
||||
schedulable={false}
|
||||
detailed={false}
|
||||
autofocus
|
||||
loading={runLoading}
|
||||
bind:scheduledForStr
|
||||
bind:invisible_to_owner
|
||||
bind:overrideTag
|
||||
actions={onOpenConfig ? configurationButton : undefined}
|
||||
/>
|
||||
</div>
|
||||
<!-- 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">
|
||||
<RunForm
|
||||
runnable={{ schema: AGENT_CHAT_SCHEMA, path }}
|
||||
runAction={runOnce}
|
||||
schedulable={false}
|
||||
detailed={false}
|
||||
autofocus
|
||||
loading={runLoading}
|
||||
bind:scheduledForStr
|
||||
bind:invisible_to_owner
|
||||
bind:overrideTag
|
||||
actions={onOpenConfig ? configurationButton : undefined}
|
||||
/>
|
||||
</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
|
||||
</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}
|
||||
<PaneNotice action={{ label: 'Turn off', onClick: turnOffMemory }}>
|
||||
Managed memory keeps the conversation between runs. It's 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.
|
||||
</PaneNotice>
|
||||
{/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>
|
||||
</Pane>
|
||||
<Pane size={60} minSize={20}>
|
||||
<ModulePreviewResultViewer
|
||||
lang="deno"
|
||||
editor={undefined}
|
||||
diffEditor={undefined}
|
||||
{#if !chatGap?.memory && !readOnly}
|
||||
<PaneNotice action={{ label: 'Turn off', onClick: turnOffMemory }}>
|
||||
Managed memory keeps the conversation between runs. It's 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.
|
||||
</PaneNotice>
|
||||
{/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}
|
||||
{testJob}
|
||||
{testIsLoading}
|
||||
{scriptProgress}
|
||||
disableMock
|
||||
disableHistory
|
||||
schema={flowLocalAgentSchema(schema)}
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
|
||||
bind:testJob
|
||||
bind:testIsLoading
|
||||
bind:scriptProgress
|
||||
/>
|
||||
</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}
|
||||
<PaneNotice
|
||||
action={chatGap.noStream === 'off' && !readOnly
|
||||
? { label: 'Turn on', onClick: turnOnStreaming }
|
||||
: undefined}
|
||||
>
|
||||
{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.'}
|
||||
</PaneNotice>
|
||||
{/if}
|
||||
<!-- Hidden rather than unmounted while memory is off: switching it off mid-turn must
|
||||
</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>
|
||||
{/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}
|
||||
<PaneNotice
|
||||
action={chatGap.noStream === 'off' && !readOnly
|
||||
? { label: 'Turn on', onClick: turnOnStreaming }
|
||||
: undefined}
|
||||
>
|
||||
{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.'}
|
||||
</PaneNotice>
|
||||
{/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}
|
||||
composerSettings={onOpenConfig ? configButton : undefined}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</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
|
||||
|
||||
@@ -35,17 +35,6 @@
|
||||
let config = $derived(agent?.state?.args)
|
||||
let chatAvailable = $derived(keepsManagedMemory(config?.memory))
|
||||
let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && !$userStore?.operator)
|
||||
// An agent runs as a flow preview, and the server refuses a preview to an operator, and to a
|
||||
// non-admin under another user's namespace (`require_path_read_access_for_preview`).
|
||||
let runBlockedReason = $derived(
|
||||
$userStore?.operator
|
||||
? 'Operators cannot run agents yet.'
|
||||
: !$userStore?.is_admin &&
|
||||
path.startsWith('u/') &&
|
||||
!path.startsWith(`u/${$userStore?.username}/`)
|
||||
? `Only its owner and workspace admins can run an agent under ${path.split('/').slice(0, 2).join('/')}.`
|
||||
: undefined
|
||||
)
|
||||
|
||||
let configModal: AgentConfigModal | undefined = $state(undefined)
|
||||
let shareModal: ShareModal | undefined = $state(undefined)
|
||||
@@ -94,7 +83,7 @@
|
||||
<div class="flex gap-1 items-center pr-4">
|
||||
<!-- 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 && !runBlockedReason && testPane?.mode}
|
||||
{#if chatAvailable && testPane?.mode}
|
||||
<ToggleButtonGroup
|
||||
bind:selected={
|
||||
() => testPane?.mode,
|
||||
@@ -176,7 +165,6 @@
|
||||
workspace={ws}
|
||||
enableAi={$copilotInfo.enabled}
|
||||
view
|
||||
{runBlockedReason}
|
||||
onOpenConfig={() => configModal?.open()}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
Reference in New Issue
Block a user