feat: build, test and run standalone ai agents from ai sessions (#11954)

* 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>
This commit is contained in:
hugocasa
2026-10-27 10:46:43 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f2f38ddd4f
commit 8dd46a4d14
66 changed files with 2537 additions and 549 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ use crate::jobs::{
/// 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`).
const AGENT_NODE_ID: &str = "__wm_agent_root";
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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@
},
{
"name": "open_page",
"description": "Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. \"show me the failed runs of X\", \"open the schedule for Y\", \"open the git sync settings\", \"open the kafka triggers\"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource's credentials, set a variable's value — pass open with the item path so its editor opens directly). Never offer page \"compare\" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.",
"description": "Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. \"show me the failed runs of X\", \"open the schedule for Y\", \"open the git sync settings\", \"open the kafka triggers\"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource's credentials, set a variable's value — pass open with the item path so its editor opens directly). Never offer page \"compare\" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, agents, pipelines). Only pages listed for this user are available; do not offer others.",
"parameters": {
"type": "object",
"properties": {
@@ -211,18 +211,19 @@
},
{
"name": "get_instructions",
"description": "Get authoring guidance for scripts, flows, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.",
"description": "Get authoring guidance for scripts, flows, AI agents, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.",
"parameters": {
"type": "object",
"properties": {
"subject": {
"description": "What to get authoring instructions for: a workspace item type (script, flow, resource, app), \"pipeline\" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or \"datatable\" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables, or \"workspace_settings\" for the keys and rules of the workspace settings (admins only). Schedules, triggers, and variables don't need instructions — their tool schemas describe everything.",
"description": "What to get authoring instructions for: a workspace item type (script, flow, resource, app), \"agent\" for a saved AI agent (an `ai_agent` resource, as the Agents page lists it), \"pipeline\" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or \"datatable\" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables, or \"workspace_settings\" for the keys and rules of the workspace settings (admins only). Schedules, triggers, and variables don't need instructions — their tool schemas describe everything.",
"type": "string",
"enum": [
"script",
"flow",
"resource",
"app",
"agent",
"datatable",
"pipeline",
"workspace_settings"
@@ -1179,6 +1180,96 @@
},
"read_only": false
},
{
"name": "test_run_agent",
"description": "Execute a test run of an AI agent by path, preferring its draft when one exists, so an agent you drafted can be tried before the user deploys it. It runs with exactly the `args` you send.",
"parameters": {
"type": "object",
"properties": {
"path": {
"description": "Workspace path of the agent (`ai_agent` resource) to test.",
"type": "string"
},
"args": {
"description": "The run's inputs: `user_message`, and `user_attachments` for files the user attaches.",
"type": "object",
"properties": {
"user_message": {
"description": "The message the agent answers.",
"type": "string"
}
},
"required": [
"user_message"
],
"additionalProperties": {}
},
"memory_id": {
"description": "A UUID naming the conversation this turn belongs to: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.",
"type": "string"
},
"background": {
"description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.",
"type": "boolean"
},
"wait_seconds": {
"description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.",
"type": "number"
}
},
"required": [
"path",
"args"
],
"additionalProperties": false
},
"read_only": false
},
{
"name": "run_agent",
"description": "Run a DEPLOYED AI agent for real, as its own page runs it. It runs with exactly the `args` you send. Use only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\").",
"parameters": {
"type": "object",
"properties": {
"path": {
"description": "Workspace path of the deployed agent (`ai_agent` resource) to run.",
"type": "string"
},
"args": {
"description": "The run's inputs: `user_message`, and `user_attachments` for files the user attaches.",
"type": "object",
"properties": {
"user_message": {
"description": "The message the agent answers.",
"type": "string"
}
},
"required": [
"user_message"
],
"additionalProperties": {}
},
"memory_id": {
"description": "A UUID naming the conversation this turn belongs to: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.",
"type": "string"
},
"background": {
"description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.",
"type": "boolean"
},
"wait_seconds": {
"description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.",
"type": "number"
}
},
"required": [
"path",
"args"
],
"additionalProperties": false
},
"read_only": false
},
{
"name": "test_run_step",
"description": "Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. An AI agent step takes the inputs a run supplies rather than code arguments, `user_message` above all; its form opens on the step's own configuration, so send only what this run should change. An AI decision step takes `state`, what its questions are asked about; its provider and questions are the step's own. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.",
+4 -3
View File
@@ -116,8 +116,8 @@ const DESTRUCTIVE: &[&str] = &[
/// nothing on the tools server, so it runs nothing here either.
const RUNS_A_JOB: &[&str] = &["get_db_schema"];
/// Tools that run a deployed script or flow, the one write a read-and-run token keeps.
const RUNS_DEPLOYED: &[&str] = &["run_script", "run_flow"];
/// Tools that run a deployed script, flow or agent, the one write a read-and-run token keeps.
const RUNS_DEPLOYED: &[&str] = &["run_script", "run_flow", "run_agent"];
fn allowed(def: &ToolDef, access: SessionAccess) -> bool {
let read = def.read_only && !RUNS_A_JOB.contains(&def.name.as_str());
@@ -368,7 +368,7 @@ fn guarded_url(
// A run also stores its secret arguments as expiring variables; the write tools that
// could create other variables are not offered to a read-and-run token.
let run_writes = method == "POST"
&& ["jobs/run/p/", "jobs/run/f/"]
&& ["jobs/run/p/", "jobs/run/f/", "jobs/run/agent/"]
.iter()
.any(|run| route.starts_with(&format!("/w/{w_id}/{run}")))
|| method == "POST" && route == format!("/w/{w_id}/variables/create");
@@ -582,6 +582,7 @@ mod tests {
// preview takes code.
assert!(url("POST", "/w/ws/jobs/run/p/u/a/b", ReadAndRun).is_ok());
assert!(url("POST", "/w/ws/jobs/run/f/u/a/b", ReadAndRun).is_ok());
assert!(url("POST", "/w/ws/jobs/run/agent/u/a/b", ReadAndRun).is_ok());
assert!(url("POST", "/w/ws/jobs/run/preview", ReadAndRun).is_err());
assert!(url("POST", "/w/ws/drafts/update", ReadAndRun).is_err());
assert!(url("POST", "/w/ws/variables/create", ReadAndRun).is_ok());
@@ -17,6 +17,8 @@ pub(super) const TOOLS: &[&str] = &[
"test_run_flow",
"run_flow",
"test_run_step",
"test_run_agent",
"run_agent",
"list_runs",
"get_run",
"cancel_job",
@@ -29,6 +31,8 @@ pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult
"test_run_flow" => test_run_flow(ctx, args).await,
"run_flow" => run_deployed(ctx, args, true).await,
"test_run_step" => test_run_step(ctx, args).await,
"test_run_agent" => test_run_agent(ctx, args).await,
"run_agent" => run_agent(ctx, args).await,
"list_runs" => list_runs(ctx, args).await,
"get_run" => get_run(ctx, args).await,
"cancel_job" => cancel_job(ctx, args).await,
@@ -707,6 +711,129 @@ async fn test_run_flow(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
conclude(ctx, args, prepared.notes, started, path, true, "test").await
}
// ============================================================================
// Saved AI agents
// ============================================================================
/// What a run of an agent takes, as its editor's chat declares it.
fn agent_run_schema() -> Value {
json!({
"type": "object",
"properties": {
"user_message": { "type": "string", "description": "Message from user" },
"user_attachments": {
"type": "array",
"items": { "type": "object", "resourceType": "s3object" },
"description": "Images or PDFs for the agent to read"
}
},
"required": ["user_message"]
})
}
fn agent_memory_id(args: &Value) -> Result<Option<&str>, String> {
let memory_id = opt_str_arg(args, "memory_id");
if memory_id.is_some_and(|id| id.len() != 36 || uuid::Uuid::parse_str(id).is_err()) {
return Err("memory_id must be a UUID".to_string());
}
Ok(memory_id)
}
/// The agent's configuration a test runs: the user's draft when there is one, else the deployed
/// resource. Refuses a path holding some other kind of resource.
async fn load_agent_for_test(ctx: &Ctx<'_>, path: &str) -> Result<Value, String> {
let draft = get_draft(ctx, "resource", path).await?.map(|d| d.value);
// A draft records its type only when nothing is deployed at its path.
let (resource_type, config) = match draft {
Some(draft) if draft.get("resource_type").is_some() => {
(draft["resource_type"].clone(), draft["args"].clone())
}
draft => {
let deployed = ctx
.get(&ctx.ws(&format!("resources/get/{}", path_enc(path))))
.await?;
let config = match draft {
Some(draft) => draft["args"].clone(),
None => deployed["value"].clone(),
};
(deployed["resource_type"].clone(), config)
}
};
let resource_type = resource_type.as_str().unwrap_or_default();
if resource_type != "ai_agent" {
return Err(format!(
"\"{path}\" is a {resource_type} resource, not an AI agent."
));
}
Ok(config)
}
async fn test_run_agent(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
let path = str_arg(args, "path")?;
let memory_id = agent_memory_id(args)?;
let mut config = load_agent_for_test(ctx, path).await?;
// Who the agent runs as and who may ask it in Slack, not inputs: a test runs as its caller.
if let Some(map) = config.as_object_mut() {
for key in ["on_behalf_of", "run_on_behalf_of", "slack_anyone"] {
map.remove(key);
}
}
// The run the agent editor's chat makes of its draft, which keeps none of the inputs a flow
// step supplies (`agentChatFlow`).
let flow = crate::ai_evals::run::config_to_draft(config)
.and_then(|config| {
crate::agent_runs::agent_step_flow(
&config,
crate::agent_runs::AGENT_NODE_ID,
true,
&["memory_id", "previous_messages", "enabled_tools"],
)
})
.map_err(|e| e.to_string())?;
let flow = serde_json::to_value(flow).map_err(|e| e.to_string())?;
let schema = agent_run_schema();
let prepared = match prepare_args(ctx, path, proposed_args(args), Some(&schema), "agent").await
{
Ok(prepared) => prepared,
Err(message) => return Ok(message),
};
let memory_id = memory_id
.map(String::from)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
// Filed where the agent editor's chat files its conversations.
let started = run_flow_preview(
ctx,
Some(&format!("{path}.chat")),
&flow,
Value::Object(prepared.args),
Some(&memory_id),
)
.await;
conclude(ctx, args, prepared.notes, started, path, true, "test").await
}
async fn run_agent(ctx: &Ctx<'_>, args: &Value) -> ToolResult {
let path = str_arg(args, "path")?;
let memory_id = agent_memory_id(args)?;
let schema = agent_run_schema();
let prepared = match prepare_args(ctx, path, proposed_args(args), Some(&schema), "agent").await
{
Ok(prepared) => prepared,
Err(message) => return Ok(message),
};
let query = memory_id
.map(|id| format!("?memory_id={}", enc(id)))
.unwrap_or_default();
let started = ctx
.post(
&ctx.ws(&format!("jobs/run/agent/{}{query}", path_enc(path))),
Value::Object(prepared.args),
)
.await
.map(job_id);
conclude(ctx, args, prepared.notes, started, path, true, "run").await
}
// ============================================================================
// One step of a flow
// ============================================================================