mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-11-01 16:13:06 +00:00
* feat: guidance for building standalone ai agents in ai sessions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: agent guidance for resource-only sessions, no flow tools Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: tell the user to deploy a drafted tool script with its agent Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: scope plain agent values, document tool_inputs, list agents in get_instructions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: test and run ai agents from ai sessions, open agents in their editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: full-bleed agent editor tabs, back to the run form for agent tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: describe read-and-run as running agents too, pin agent run links Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: host the agent editor natively in session tabs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: switch the agent editor to tabs below 900px Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: name an agent's session tab after its summary Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show deployed agents in session tabs, open and run them from the chat Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: deploy agents from the chat, report agent tabs as the active editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: pick agents from the tab picker, name their tabs, scope the agent page to its workspace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: leave open editors alone on agent writes, read the agent page's workspace user Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refresh an agent's deployed page when the chat deploys or deletes it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: name agent tabs before they load, mark agents in the changes drawer, stay on a deleted agent's tab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep a cleared agent summary out of its tab name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the resource drawer's json option that the resources list still passes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: open an agent in an AI session from its pages, fix review findings on agent tabs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: name the reserved agent tool names in the agent guidance Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: reload an agent's editor on a discard, open a new agent in a session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show agents as agents on compare and deploy, name a never-deployed one by its path Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep a deployed agent's type in its draft diff Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: link a fork row to the agent editor only on the side that holds the agent Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: guard agent page paths, mark drafted agents in the picker, side-aware agent rows on fork compare Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
361 lines
13 KiB
Rust
361 lines
13 KiB
Rust
//! 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, or as
|
|
//! the agent's `on_behalf_of` when its `run_on_behalf_of` is set; the server resolves
|
|
//! `on_behalf_of` on every write of the agent.
|
|
|
|
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},
|
|
variables::untrusted_arg_interpolates,
|
|
};
|
|
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, run_end_user_email, set_flow_memory_id, RunJobQuery,
|
|
};
|
|
|
|
/// The id the agent's step carries in the run, as the editor's chat names it. A token scoped to
|
|
/// `jobs:run:agents:<path>` reads its runs back by it (`require_job_within_read_scope`).
|
|
pub(crate) const AGENT_NODE_ID: &str = "__wm_agent_root";
|
|
|
|
/// A turn of the agent's chat when `memory_id` names a conversation, a single run otherwise.
|
|
/// Returns the job id.
|
|
pub(crate) async fn run_agent(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((w_id, path)): Path<(String, StripPath)>,
|
|
Query(run_query): Query<RunJobQuery>,
|
|
Json(args): Json<HashMap<String, Box<RawValue>>>,
|
|
) -> Result<(StatusCode, String)> {
|
|
let path = path.to_path();
|
|
check_scopes(&authed, || format!("jobs:run:agents:{path}"))?;
|
|
|
|
let uuid = run_agent_as(&authed, &db, &user_db, &w_id, path, &run_query, &args).await?;
|
|
Ok((StatusCode::CREATED, uuid.to_string()))
|
|
}
|
|
|
|
/// [`run_agent`] for a caller already authenticated, such as the identity of an agent shared in a
|
|
/// Slack channel.
|
|
pub(crate) async fn run_agent_as(
|
|
authed: &ApiAuthed,
|
|
db: &DB,
|
|
user_db: &UserDB,
|
|
w_id: &str,
|
|
path: &str,
|
|
run_query: &RunJobQuery,
|
|
args: &HashMap<String, Box<RawValue>>,
|
|
) -> Result<uuid::Uuid> {
|
|
#[cfg(feature = "enterprise")]
|
|
crate::jobs::check_license_key_valid().await?;
|
|
|
|
// Read through the caller's own permissions: reading the agent is what allows running it.
|
|
let chat = run_query.memory_id.is_some();
|
|
let AgentTurn { flow_value, run_path, agent_obo } =
|
|
agent_turn(authed, user_db, w_id, path, chat).await?;
|
|
|
|
let on_behalf_of =
|
|
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.
|
|
let (email, permissioned_as, push_authed, isolation) = if let Some(obo) = on_behalf_of.as_ref()
|
|
{
|
|
(
|
|
&obo.email,
|
|
obo.permissioned_as.clone(),
|
|
None,
|
|
PushIsolationLevel::IsolatedRoot(db.clone()),
|
|
)
|
|
} else {
|
|
(
|
|
&authed.email,
|
|
username_to_permissioned_as(&authed.username),
|
|
Some(authed.clone().into()),
|
|
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
|
|
)
|
|
};
|
|
if let Some(obo) = on_behalf_of.as_ref() {
|
|
// The worker would interpolate such an input with the job's token, which is the
|
|
// identity's.
|
|
if args.values().any(|v| untrusted_arg_interpolates(v.get())) {
|
|
return Err(Error::BadRequest(format!(
|
|
"Agent {path} runs on behalf of {}, so its inputs cannot reference variables or resources",
|
|
obo.permissioned_as
|
|
)));
|
|
}
|
|
#[cfg(all(feature = "parquet", feature = "private"))]
|
|
check_attachments_readable(db, authed, w_id, args).await?;
|
|
}
|
|
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
|
|
let end_user_email = run_end_user_email(db, w_id, authed, None).await?;
|
|
let (uuid, mut tx) = push(
|
|
db,
|
|
isolation,
|
|
w_id,
|
|
JobPayload::RawFlow {
|
|
value: flow_value,
|
|
path: Some(run_path.clone()),
|
|
restarted_from: None,
|
|
},
|
|
PushArgs::from(args),
|
|
authed.display_username(),
|
|
email,
|
|
permissioned_as,
|
|
authed.token_prefix.as_deref(),
|
|
authed.username_override.as_deref(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
push_authed.as_ref(),
|
|
false,
|
|
end_user_email,
|
|
authed.trigger_or_fallback(None),
|
|
None,
|
|
scope_ceiling.as_deref(),
|
|
)
|
|
.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 {
|
|
// The caller's conversation, whoever the agent runs as, and a real one: this is the
|
|
// agent as deployed, not a draft under test.
|
|
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(uuid)
|
|
}
|
|
|
|
/// A turn of a saved agent, as the flow that runs it.
|
|
pub(crate) struct AgentTurn {
|
|
pub flow_value: windmill_common::flows::FlowValue,
|
|
/// Where the turn is filed: a chat turn under `<path>.chat`, which a flow cannot name (flow
|
|
/// paths carry no `.`), so the agent's conversations never share a list with a flow's.
|
|
pub run_path: String,
|
|
/// The identity the agent runs as when it opts in (`run_on_behalf_of`). A caller that runs it
|
|
/// as some other identity, such as an app's, ignores it.
|
|
pub agent_obo: Option<String>,
|
|
}
|
|
|
|
/// Reads the agent through `authed`'s own permissions and builds the flow of one of its turns.
|
|
/// Who it may see and which inputs or slack settings it carries are not inputs of the run.
|
|
pub(crate) async fn agent_turn(
|
|
authed: &ApiAuthed,
|
|
user_db: &UserDB,
|
|
w_id: &str,
|
|
path: &str,
|
|
chat: bool,
|
|
) -> Result<AgentTurn> {
|
|
let mut tx = user_db.clone().begin(authed).await?;
|
|
let value = sqlx::query_scalar!(
|
|
"SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\"
|
|
FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
|
|
w_id,
|
|
path
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
let Some(sqlx::types::Json(mut value)) = not_found_if_none(value, "Agent", path)? else {
|
|
return Err(Error::BadRequest(format!(
|
|
"Agent {path} has no configuration"
|
|
)));
|
|
};
|
|
let obj = value.as_object_mut();
|
|
let (agent_obo, run_obo) = obj
|
|
.map(|o| {
|
|
o.remove("slack_anyone");
|
|
(o.remove("on_behalf_of"), o.remove("run_on_behalf_of"))
|
|
})
|
|
.unwrap_or_default();
|
|
let agent_obo = agent_obo
|
|
.filter(|_| run_obo.and_then(|v| v.as_bool()).unwrap_or(false))
|
|
.and_then(|v| v.as_str().map(str::to_string));
|
|
let config = config_to_draft(value)?;
|
|
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"],
|
|
)?;
|
|
Ok(AgentTurn { flow_value, run_path, agent_obo })
|
|
}
|
|
|
|
/// The step fetches the message's files with its job's token, so a run as the agent's identity
|
|
/// would read any file that identity can: a caller attaches only what they could download
|
|
/// themselves, checked as `sign_s3_objects` checks a key before signing it.
|
|
#[cfg(all(feature = "parquet", feature = "private"))]
|
|
pub(crate) async fn check_attachments_readable(
|
|
db: &DB,
|
|
authed: &ApiAuthed,
|
|
w_id: &str,
|
|
args: &HashMap<String, Box<RawValue>>,
|
|
) -> Result<()> {
|
|
use crate::job_helpers_oss::{
|
|
get_large_file_storage, get_workspace_s3_resource_and_check_paths,
|
|
};
|
|
use windmill_common::db::DbWithOptAuthed;
|
|
use windmill_types::s3::S3Permission;
|
|
|
|
for attachment in run_attachments(args)? {
|
|
// The path check is skipped, not failed, when the storage does not resolve.
|
|
if get_large_file_storage(db, w_id, attachment.storage.clone())
|
|
.await?
|
|
.is_none()
|
|
{
|
|
return Err(windmill_object_store::workspace_storage_not_found(
|
|
attachment.storage.as_deref(),
|
|
));
|
|
}
|
|
get_workspace_s3_resource_and_check_paths(
|
|
&DbWithOptAuthed::from_authed(authed, db.clone(), None),
|
|
Some(authed),
|
|
w_id,
|
|
attachment.storage.clone(),
|
|
&[(&attachment.s3, S3Permission::READ)],
|
|
None,
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Every file the step fetches with its job's token: the message's attachments, and the
|
|
/// `s3_object` parts of the history a caller supplies, which the worker downloads alike.
|
|
pub(crate) fn run_attachments(
|
|
args: &HashMap<String, Box<RawValue>>,
|
|
) -> Result<Vec<windmill_types::s3::S3Object>> {
|
|
use windmill_ai::ai_types::{ContentPart, OpenAIContent, OpenAIMessage};
|
|
|
|
let mut files: Vec<windmill_types::s3::S3Object> = match args.get("user_attachments") {
|
|
Some(raw) => serde_json::from_str::<Option<Vec<_>>>(raw.get())
|
|
.map_err(|e| Error::BadRequest(format!("invalid user_attachments: {e}")))?
|
|
.unwrap_or_default(),
|
|
None => vec![],
|
|
};
|
|
if let Some(raw) = args.get("previous_messages") {
|
|
let messages = serde_json::from_str::<Option<Vec<OpenAIMessage>>>(raw.get())
|
|
.map_err(|e| Error::BadRequest(format!("invalid previous_messages: {e}")))?;
|
|
for message in messages.into_iter().flatten() {
|
|
if let Some(OpenAIContent::Parts(parts)) = message.content {
|
|
files.extend(parts.into_iter().filter_map(|part| match part {
|
|
ContentPart::S3Object { s3_object } => Some(s3_object),
|
|
_ => None,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
files.retain(|f| !f.s3.is_empty());
|
|
Ok(files)
|
|
}
|
|
|
|
/// 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, the attachments and any history over the top. Always inlined, never a link
|
|
/// to the resource: a linked step would resolve the agent when it runs, not as it was read here.
|
|
pub(crate) fn agent_step_flow(
|
|
config: &AgentDraft,
|
|
id: &str,
|
|
chat: bool,
|
|
without: &[&str],
|
|
) -> Result<windmill_common::flows::FlowValue> {
|
|
let mut input_transforms = match &config.input_transforms {
|
|
serde_json::Value::Object(map) => map.clone(),
|
|
_ => serde_json::Map::new(),
|
|
};
|
|
for key in without {
|
|
input_transforms.remove(*key);
|
|
}
|
|
for key in ["user_message", "user_attachments", "previous_messages"] {
|
|
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)?)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn run_attachments_include_the_files_a_supplied_history_quotes() {
|
|
let args: HashMap<String, Box<RawValue>> = serde_json::from_value(serde_json::json!({
|
|
"user_attachments": [{ "s3": "u/a/attached.png" }],
|
|
"previous_messages": [
|
|
{ "role": "user", "content": [
|
|
{ "type": "text", "text": "look" },
|
|
{ "type": "s3_object", "s3_object": { "s3": "u/b/quoted.png" } }
|
|
]},
|
|
{ "role": "assistant", "content": "ok" }
|
|
]
|
|
}))
|
|
.unwrap();
|
|
let keys: Vec<_> = run_attachments(&args).unwrap().into_iter().map(|f| f.s3).collect();
|
|
assert_eq!(keys, ["u/a/attached.png", "u/b/quoted.png"]);
|
|
}
|
|
}
|