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
// ============================================================================
+1 -1
View File
@@ -23,7 +23,7 @@ pub struct McpScopeConfig {
/// The workspaces a token without a bound workspace may act in
/// (`mcp:workspaces:a,b`); `None` is every workspace its owner belongs to.
pub workspaces: Option<Vec<String>>,
/// `mcp:read_run`: reads, plus running deployed scripts and flows, and nothing else.
/// `mcp:read_run`: reads, plus running deployed scripts, flows and agents, and nothing else.
pub read_run: bool,
/// `mcp:read_only`: reads alone, as the token's read-only flag does. An OAuth grant
/// carries scopes and no flag, so this is how a sign-in asks for read-only access.
+2 -2
View File
@@ -17,8 +17,8 @@ use crate::server::endpoints::EndpointTool;
/// Result type for backend operations using rmcp's ErrorData directly
pub type BackendResult<T> = Result<T, ErrorData>;
/// What a v2 token may do: read, read and run deployed scripts and flows (`mcp:read_run`),
/// or everything its owner can.
/// What a v2 token may do: read, read and run deployed scripts, flows and agents
/// (`mcp:read_run`), or everything its owner can.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionAccess {
ReadOnly,
+134 -6
View File
@@ -32,6 +32,7 @@ export const SKILLS: SkillMetadata[] = [
{ name: "triggers", description: "MUST use when configuring triggers." },
{ name: "schedules", description: "MUST use when configuring schedules." },
{ name: "resources", description: "MUST use when managing resources." },
{ name: "agents", description: "MUST use when creating or changing an AI agent, the reusable `ai_agent` resource the Agents page lists." },
{ name: "write-workflow-as-code", description: "MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow." },
{ name: "write-pipeline", description: "MUST use when creating or modifying a data pipeline, a set of scripts marked `pipeline` and wired together by `on` / `materialize` annotations." },
{ name: "datatables", description: "MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions." },
@@ -5911,11 +5912,12 @@ value:
## AI Agent Modules
An \`aiagent\` module runs an LLM that can call tools. Each entry of \`value.tools\` is a module-shaped
object with an extra \`value.tool_type\`: \`flowmodule\` for a script/flow tool, \`mcp\` for an MCP server
tool, \`websearch\` for web search, \`question\` for a built-in \`ask_question\` tool (the agent stops,
without holding a worker, until someone answers from the run page or the chat; optional
\`value.timeout\` in seconds, default 1800). Set \`requires_approval: true\` on a \`flowmodule\` or \`mcp\`
tool to have a person approve or reject each of its calls before it runs.
object with an extra \`value.tool_type\`: \`flowmodule\` for a script or inline script tool (or a nested
agent or AI decision), \`mcp\` for an MCP server tool, \`websearch\` for web search, \`question\` for a
built-in \`ask_question\` tool (the agent stops, without holding a worker, until someone answers from
the run page or the chat; optional \`value.timeout\` in seconds, default 1800). Set
\`requires_approval: true\` on a \`flowmodule\` or \`mcp\` tool to have a person approve or reject each of
its calls before it runs.
\`\`\`json
{
@@ -5942,7 +5944,7 @@ tool to have a person approve or reject each of its calls before it runs.
"type": "rawscript",
"language": "bun",
"content": "export async function main(query: string) { return ['doc1', 'doc2']; }",
"input_transforms": { "query": { "type": "static", "value": "" } }
"input_transforms": { "query": { "type": "ai" } }
}
}
]
@@ -5950,6 +5952,11 @@ tool to have a person approve or reject each of its calls before it runs.
}
\`\`\`
- A flow cannot be a tool: a run offered one fails with \`Unsupported tool\`. Wrap it in a script
that runs the flow
- Each argument of a tool is either the model's or fixed. \`{ "type": "ai" }\` (or no value) lets the
model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix
what the model must not choose, such as a resource, a table name or a limit
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved
agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its
@@ -7220,6 +7227,7 @@ Example: \`f/databases/postgres_prod.resource.json\`
An agent, as the Agents page lists it, is a resource of type \`ai_agent\`. Its value holds the
agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by
linking its path.
Before writing one, use the \`agents\` skill.
## Variable References
@@ -7487,6 +7495,126 @@ wmill resource-type get postgresql
# deploy via \`git push\` or \`wmill sync push\` (see the Deploying section in AGENTS.wmill.md).
wmill sync push
\`\`\`
`,
"agents": `---
name: agents
description: MUST use when creating or changing an AI agent, the reusable \`ai_agent\` resource the Agents page lists.
---
# Windmill AI Agents
An agent, as the Agents page lists it, is a saved LLM with its own system prompt, tools and
memory, stored as a resource of type \`ai_agent\`. It is built once and used in several places: it is
run from its own page (a chat, or a run form), a flow's AI agent step links to it by path, and it
can be shared in Slack channels from its page. Build a standalone agent when the user wants an
assistant they talk to or reuse. Build a flow when the agent is one step of a larger process.
## File Format
An agent is a resource file like any other, \`{path}.resource.yaml\` (or \`.resource.json\`), with
\`resource_type: ai_agent\`.
## Structure
\`\`\`json
{
"resource_type": "ai_agent",
"description": "Answers support questions from the product docs",
"value": {
"provider": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" },
"system_prompt": "You are the support assistant for Acme. Answer from the docs; say so when they don't cover a question.",
"memory": { "kind": "compaction" },
"output_type": "text",
"tools": [
{
"id": "search_docs",
"summary": "search_documentation",
"description": "Search the product documentation. Use it whenever the user asks how a feature works.",
"value": {
"tool_type": "flowmodule",
"type": "script",
"path": "f/support/search_docs",
"input_transforms": {
"query": { "type": "ai" },
"index": { "type": "static", "value": "product_docs" }
}
}
}
]
}
}
\`\`\`
The agent's own settings are plain values, not the \`input_transforms\` an agent step in a flow uses:
each is fixed when the agent is saved. Only a tool's arguments keep \`input_transforms\` (see Tools). \`$res:\` and \`$var:\` references inside it are resolved at run time.
- \`provider\` (required): \`{ "kind", "resource": "$res:<path>", "model" }\`, plus an optional
\`reasoning_effort\`. \`resource\` names the workspace's AI provider resource of that kind
- \`system_prompt\`: how the agent behaves. Write it for the agent's job: who it serves, what it may
do with its tools, and what it should refuse or hand back
- \`memory\`: what lets the agent see earlier turns. \`{ "kind": "compaction" }\` keeps the whole
conversation, summarizing its older part as it nears the model's context window; it is what a new
agent starts with on its page. \`{ "kind": "off" }\` makes every run start from nothing, right for an
agent that handles one request per run
- \`output_type\`: \`text\` (default) or \`image\`. Image output ignores tools and needs S3 storage on the
workspace
- \`output_schema\`: a JSON schema the answer must follow, for an agent whose output a program reads
- \`max_iterations\`: how many model and tool rounds a run may take, 1 to 1000 (default 10)
- \`streaming\` (default on), \`temperature\`, \`max_completion_tokens\`: as their names say
- \`run_on_behalf_of\`: runs from the agent's page take the identity the agent was deployed with
rather than the caller's, so people can use it without access to what its tools touch.
\`slack_anyone\` lets anyone in the Slack channels it is shared in ask it, and is kept only with
\`run_on_behalf_of\`. Set either only when the user asks: both widen who acts with the deployer's
rights
- \`on_behalf_of\` is the identity itself. Deploying sets it, so never write it
## Tools
\`tools\` takes the same entries as a flow's AI agent step. \`value.tool_type\` is \`flowmodule\` for a
script or inline script the agent calls (or a nested agent or AI decision), \`mcp\` for an MCP server
(\`{ "tool_type": "mcp", "resource_path": "$res:<path>", "include_tools": [], "exclude_tools": [] }\`),
or \`websearch\` for web search (\`{ "tool_type": "websearch" }\`, summary \`Web Search\`).
- Write logic made for this agent as an inline \`rawscript\` tool, as a flow writes its steps. Point a
\`script\` tool at a deployed script (\`path\`) only when one already does the job: a run calls the
deployed version, so a script that exists only as a draft fails the run. An inline tool's
\`value\` is a flow's rawscript step:
\`{ "tool_type": "flowmodule", "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { ... }", "input_transforms": { "query": { "type": "ai" } } }\`
- A flow cannot be a tool: a run offered one fails with \`Unsupported tool\`. Wrap it in a script
that runs the flow
- A flowmodule tool's \`summary\` is the name the agent calls it by: letters, numbers and underscores
only, unique among the agent's tools, and neither a reserved step id (a JavaScript keyword, \`ctx\`,
\`state\`, \`bg\`) nor \`__wm_web_search\`. Put the explanation in \`description\`, which tells the agent
when to call it
- Each argument of a tool is either the model's or fixed. \`{ "type": "ai" }\` (or no value) lets the
model fill it; a static value fixes it and hides it from the model. Fix what the model must not
choose, such as a resource, a table name or a limit
## Using It From a Flow
An AI agent step links to a saved agent with \`value.agent\` instead of carrying its own \`provider\`,
system prompt and tools:
\`\`\`json
{
"id": "support",
"value": {
"type": "aiagent",
"agent": "f/support/support_agent",
"input_transforms": {
"user_message": { "type": "javascript", "expr": "flow_input.question" }
},
"tools": []
}
}
\`\`\`
The step holds only what belongs to one use of the agent: \`user_message\`, \`user_attachments\`,
\`enabled_tools\`, \`memory_id\`, \`previous_messages\`, and the \`model\` and \`reasoning_effort\` overrides.
Its \`value.tool_inputs\` (\`{ "<tool id>": { "<argument>": <input transform> } }\`) wires a saved
tool's arguments to this flow, e.g. \`flow_input.customer_id\`, over the transforms the agent saved,
without changing the agent. Everything else comes from the agent, so a change to the agent reaches
every flow linking it.
`,
"write-workflow-as-code": `---
name: write-workflow-as-code
@@ -14,7 +14,13 @@
import { untrack } from 'svelte'
import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte'
import { editUrlFor } from './sessions/forkEditUrl'
import { AppService, FlowService, ScriptService, type WorkspaceItemDiff } from '$lib/gen'
import {
AppService,
FlowService,
ResourceService,
ScriptService,
type WorkspaceItemDiff
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import {
getDraftDiffValues,
@@ -253,13 +259,45 @@
// side to diff the name against. Raw apps are fetched via the apps endpoint too
// (it auto-detects raw from the deployed row and overlays the raw_app draft).
const summaryCache = $state<
Record<string, { deployed?: string; draft?: string; stale?: boolean; loading?: boolean }>
Record<
string,
{
deployed?: string
draft?: string
stale?: boolean
loading?: boolean
/** A resource row that is an AI agent, and the path its draft names. */
agent?: boolean
draftPath?: string
}
>
>({})
async function fetchDraftSummary(item: Row) {
if (summaryCache[item.key]) return
summaryCache[item.key] = { loading: true }
try {
// The drafts list says neither which resources are agents nor where a never-named one
// will deploy, so an agent row is read for its summary and its draft's path.
if (item.draftKind === 'resource') {
const r = await ResourceService.getResource({
workspace: currentWorkspaceId,
path: item.path,
getDraft: true
})
const draft = r.draft as { path?: string; description?: string; resource_type?: string }
const agent = (draft?.resource_type ?? r.resource_type) === 'ai_agent'
summaryCache[item.key] = agent
? {
agent,
deployed: item.draft_only ? undefined : r.description,
draft: draft?.description ?? r.description,
draftPath: draft?.path,
loading: false
}
: { loading: false }
return
}
const r = (await (item.draftKind === 'script'
? ScriptService.getScriptByPath({
workspace: currentWorkspaceId,
@@ -296,8 +334,9 @@
for (const item of current) {
if (
item.mine &&
!item.draft_only &&
(['script', 'flow', 'app'].includes(item.draftKind) || item.raw_app) &&
((!item.draft_only &&
(['script', 'flow', 'app'].includes(item.draftKind) || item.raw_app)) ||
item.draftKind === 'resource') &&
!summaryCache[item.key]
) {
void fetchDraftSummary(item)
@@ -611,6 +650,9 @@
? `/pipeline/${encodeURIComponent(folder)}?workspace=${encodeURIComponent(currentWorkspaceId)}`
: undefined
}
if (summaryCache[d.key]?.agent) {
return `/agents/edit/${d.path}?workspace=${encodeURIComponent(currentWorkspaceId)}`
}
const listPage = LIST_PAGE_FOR_KIND[d.draftKind]
if (listPage) {
return `${listPage}?workspace=${encodeURIComponent(currentWorkspaceId)}#${d.path}`
@@ -668,6 +710,7 @@
{deploymentStatus}
{allSelected}
selectablePredicate={(item) => isDiscardable(item as unknown as Row)}
isAgent={(item) => !!summaryCache[item.key]?.agent}
selectBlockedReason={(item) => blockedReason(item as unknown as Row)}
onToggleItem={toggleItem}
onSelectAll={selectAll}
@@ -745,7 +788,7 @@
{@const oldSummary = cache?.deployed ?? draftItem.summary}
{@const newSummary = cache?.draft ?? draftItem.summary}
<WorkspaceDeployItemSummary
path={displayPath(draftItem)}
path={cache?.agent ? (cache.draftPath ?? draftItem.path) : displayPath(draftItem)}
{editUrl}
{oldSummary}
{newSummary}
@@ -758,7 +801,13 @@
{#snippet itemPath(item)}
{@const draftItem = item as unknown as Row}
{#if draftItem.kind === 'resource' || draftItem.kind === 'variable' || draftItem.kind === 'resource_type'}
{@const agent = summaryCache[draftItem.key]?.agent
? summaryCache[draftItem.key]
: undefined}
{#if agent}
<!-- Titled by its summary like a script; a summary-less one is titled by this path. -->
{#if agent.draft}{agent.draftPath ?? draftItem.path}{/if}
{:else if draftItem.kind === 'resource' || draftItem.kind === 'variable' || draftItem.kind === 'resource_type'}
<!-- drawer-only items show their path as the title; keep this line empty -->
{:else if !draftItem.draft_only && draftItem.draft_path && draftItem.draft_path !== draftItem.path}
<!-- Path rename: a *deployed* item's draft moves it to a new path. Strike
@@ -775,17 +824,21 @@
{#snippet itemActions(item)}
{@const draftItem = item as unknown as Row}
{@const others = otherDraftUsers(draftItem)}
<Badge color="gray" size="xs">{kindLabel(draftItem.draftKind)}</Badge>
<DraftBadge
is_draft={true}
draft_only={draftItem.draft_only}
draft_users={draftItem.draft_users ?? []}
{currentUsername}
workspace={currentWorkspaceId}
itemKind={draftItem.draftKind}
path={draftItem.path}
allowFork={false}
/>
<div class="flex items-center gap-1">
<Badge color="gray" size="xs">
{summaryCache[draftItem.key]?.agent ? 'agent' : kindLabel(draftItem.draftKind)}
</Badge>
<DraftBadge
is_draft={true}
draft_only={draftItem.draft_only}
draft_users={draftItem.draft_users ?? []}
{currentUsername}
workspace={currentWorkspaceId}
itemKind={draftItem.draftKind}
path={draftItem.path}
allowFork={false}
/>
</div>
{#if draftItem.mine && others.length > 0}
<Popover openOnHover debounceDelay={50}>
{#snippet trigger()}
@@ -493,6 +493,9 @@
// Summary cache: stores summaries from both workspaces
type SummaryCache = Record<string, { current?: string; parent?: string; loading?: boolean }>
let summaryCache = $state<SummaryCache>({})
// Resource rows that are AI agents: titled by their summary and drawn as agents, as a script is.
// Per side, since a path can be an agent on one side and another resource type on the other.
let agentKeys = $state<Record<string, { current: boolean; parent: boolean }>>({})
// What has already been asked for. Deliberately not `$state`: the fetchers below run inside an
// effect, and a guard that reads reactive state makes that effect depend on what it writes —
// which is a loop. A plain Set is also what tells "asked, and the answer was nothing" from
@@ -512,6 +515,14 @@
return `${diff.kind}:${diff.path}`
}
// Drawn as an agent by the side being deployed, or by the target's when the source deletes it.
function isAgentRow(diff: WorkspaceItemDiff): boolean {
const sides = agentKeys[getItemKey(diff)]
if (!sides) return false
const fromCurrent = mergeIntoParent ? diff.exists_in_fork : !diff.exists_in_source
return fromCurrent ? sides.current : sides.parent
}
/** The two things a row needs from an item — the name it shows and who it runs as — are fields
* of the same object, so it is read once per item per workspace and awaited by both loops.
*
@@ -579,9 +590,9 @@
})
async function fetchSummaries(diffs: WorkspaceItemDiff[]) {
// Only fetch summaries for scripts, flows, and apps
// Only fetch summaries for scripts, flows, apps, and resources (for the agents among them)
const itemsToFetch = diffs.filter((diff) =>
['script', 'flow', 'app', 'raw_app', 'folder'].includes(diff.kind)
['script', 'flow', 'app', 'raw_app', 'folder', 'resource'].includes(diff.kind)
)
// Read once, so every request in this pass names the pair the pass was started for. Named
// the way the diff names the two sides: `exists_in_fork` is this workspace, `exists_in_source`
@@ -599,6 +610,33 @@
summaryCache[key] = { loading: true }
if (diff.kind === 'resource') {
const read = (ws: string) =>
ResourceService.getResource({ workspace: ws, path: diff.path }).catch((error) => {
console.error(`Failed to fetch resource ${diff.path} in ${ws}`, error)
return undefined
})
const [current, parent] = await Promise.all([
diff.exists_in_fork ? read(forkWs) : undefined,
diff.exists_in_source ? read(sourceWs) : undefined
])
if (!alive) return
const sides = {
current: current?.resource_type === 'ai_agent',
parent: parent?.resource_type === 'ai_agent'
}
const agent = sides.current || sides.parent
if (agent) agentKeys[key] = sides
summaryCache[key] = agent
? {
current: sides.current ? current?.description : undefined,
parent: sides.parent ? parent?.description : undefined,
loading: false
}
: { loading: false }
continue
}
// Only the sides that have the item. An item that is only ahead does not exist in the
// other workspace, so asking it there is a 404 by construction — a guaranteed-failing
// request per one-sided row, and a console error for each.
@@ -1325,6 +1363,7 @@
<div class="bg-surface-tertiary p-4 rounded-md border">
<WorkspaceDeployLayout
items={deployableItems}
isAgent={(item) => isAgentRow(item.diff as WorkspaceItemDiff)}
{selectedItems}
{deploymentStatus}
{allSelected}
@@ -1649,10 +1688,10 @@
<!-- Point the edit link at the workspace the item actually lives in:
a parent-only row (deleted/absent in the fork) would 404 if linked
into the fork, so link it into the parent instead. -->
{@const editUrl = editUrlFor(
diff,
diff.exists_in_fork ? currentWorkspaceId : parentWorkspaceId
)}
{@const linkWs = diff.exists_in_fork ? currentWorkspaceId : parentWorkspaceId}
{@const editUrl = agentKeys[key]?.[diff.exists_in_fork ? 'current' : 'parent']
? `/agents/edit/${diff.path}?workspace=${encodeURIComponent(linkWs)}`
: editUrlFor(diff, linkWs)}
{#if isTriggerOrScheduleKind(diff.kind)}
<span class="text-emphasis">
{KIND_DISPLAY_NAMES[diff.kind as string] ?? diff.kind}
@@ -2,6 +2,7 @@
import { type Job } from '$lib/gen'
import JobStatus from '$lib/components/JobStatus.svelte'
import { flowPathToHref } from '$lib/scripts'
import { agentRunHref } from '$lib/components/flows/conversations/agentEditorChat'
import { displayDate, truncateRev } from '$lib/utils'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import TimeAgo from './TimeAgo.svelte'
@@ -17,6 +18,8 @@
}
let { job, scheduleEditor }: Props = $props()
// An agent's run is a flow preview, linked to the agent it ran.
const agentHref = $derived(job ? agentRunHref(job, $workspaceStore) : undefined)
</script>
<div
@@ -67,9 +70,7 @@
<Bot size={SMALL_ICON_SIZE} class="min-w-3.5" />
<span class="whitespace-nowrap">
Triggered by parent
<a href={`/run/${job.parent_job}?workspace=${$workspaceStore}`}>
{job.parent_job}</a
>
<a href={`/run/${job.parent_job}?workspace=${$workspaceStore}`}> {job.parent_job}</a>
</span>
</div>
{/if}
@@ -91,12 +92,12 @@
</div>
{/if}
{#if (job && job.job_kind == 'flow') || job?.job_kind == 'script'}
{#if (job && job.job_kind == 'flow') || job?.job_kind == 'script' || agentHref}
{@const stem = `${job?.job_kind}s`}
{@const isScript = job?.job_kind === 'script'}
{@const viewHref = isScript
? `/${stem}/get/${job?.script_hash}`
: flowPathToHref(job?.script_path ?? '')}
: (agentHref ?? flowPathToHref(job?.script_path ?? ''))}
<div class="flex flex-row gap-2 items-center">
{#if isScript}
<Code2 size={SMALL_ICON_SIZE} class="min-w-3.5" />
@@ -57,6 +57,8 @@
itemPath?: Snippet<[DeployableItem]>
itemActions?: Snippet<[DeployableItem]>
footer?: Snippet
/** A resource row that is an AI agent, drawn with the agent's icon. */
isAgent?: (item: DeployableItem) => boolean
// Callbacks
onToggleItem?: (item: DeployableItem) => void
@@ -83,6 +85,7 @@
itemPath,
itemActions,
footer,
isAgent,
onToggleItem,
onSelectAll,
onDeselectAll
@@ -251,8 +254,12 @@
{@const isDeployed = status?.status === 'deployed'}
{@const blockedReason =
!isSelectable && !isDeployed ? selectBlockedReason?.(item) : undefined}
<!-- A resource is titled by its path, except an agent, titled by its summary. -->
{@const showPath =
item.kind !== 'resource' && item.kind !== 'variable' && item.kind !== 'resource_type'}
isAgent?.(item) ||
(item.kind !== 'resource' &&
item.kind !== 'variable' &&
item.kind !== 'resource_type')}
<!-- The group header already names the folder — the path line only
carries what's below it. -->
{@const subPath =
@@ -270,7 +277,7 @@
onSelect={() => handleSelect(item)}
path={showPath ? item.path : ''}
marked={undefined}
kind={item.kind}
kind={isAgent?.(item) ? 'agent' : item.kind}
triggerKind={item.triggerKind}
canFavorite={false}
workspaceId=""
@@ -170,7 +170,8 @@ import {
type ChatCommandItem,
type SessionPromptContext,
type GlobalToolHelpers,
type GlobalActivePreviewContext
type GlobalActivePreviewContext,
type GlobalActiveEditorContext
} from './global/core'
import {
assembleGlobalSystemMessage,
@@ -598,7 +599,7 @@ export class AIChatManager implements ChatViewHost {
* control elsewhere. */
openRunOnDeployedPage?: (a: {
toolCallId: string
kind: 'script' | 'flow'
kind: 'script' | 'flow' | 'agent'
path: string
summary: string
carries: boolean
@@ -946,6 +947,8 @@ export class AIChatManager implements ChatViewHost {
// a page tab is an iframe in its own realm, so the tab model is the only place the
// chat can learn it. Undefined for a live editor — ACTIVE EDITOR covers those.
activePreviewResolver: (() => GlobalActivePreviewContext | undefined) | undefined = undefined
// An editor the draft registry does not cover, such as an agent's in a session tab.
activeEditorResolver: (() => GlobalActiveEditorContext | undefined) | undefined = undefined
// Resolves the workspace this chat operates on. Session chats set it to their
// own (possibly forked) workspace so the chat targets it WITHOUT switching the
// global workspaceStore. Undefined for the global side-panel chat, which
@@ -2234,7 +2237,7 @@ export class AIChatManager implements ChatViewHost {
if (form.kind === 'run' && form.path && !form.conversational) {
this.openRunOnDeployedPage?.({
toolCallId: toolId,
kind: form.runnableKind === 'flow' ? 'flow' : 'script',
kind: form.agentPage ? 'agent' : form.runnableKind === 'flow' ? 'flow' : 'script',
path: form.path,
summary: form.summary || form.path,
carries: true,
@@ -3422,6 +3425,7 @@ export class AIChatManager implements ChatViewHost {
{
workspace: this.operatingWorkspace,
activePreview: this.activePreviewResolver?.(),
activeEditor: this.activeEditorResolver?.(),
resourceTypeSync: this.resourceTypeSync
}
)
@@ -4268,6 +4272,7 @@ export class AIChatManager implements ChatViewHost {
userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, {
workspace: this.operatingWorkspace,
activePreview: this.activePreviewResolver?.(),
activeEditor: this.activeEditorResolver?.(),
images: sentImages,
files: files,
resourceTypeSync: this.resourceTypeSync
@@ -7,6 +7,8 @@
import { Loader2 } from 'lucide-svelte'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import VariableEditor from '$lib/components/VariableEditor.svelte'
import { goto } from '$app/navigation'
import { agentEditHref } from './workspaceItems.svelte'
import type { CreatedResourceTriggerKind, ToolDisplayAction } from './shared'
type DrawerKey = 'schedule' | CreatedResourceTriggerKind
@@ -122,6 +124,11 @@
return
}
if (action.resource === 'agent') {
await goto(agentEditHref(action.path))
return
}
if (action.resource === 'resource') {
await resourceEditorDrawer?.initEdit(action.path)
return
@@ -39,8 +39,9 @@
const draft = untrack(() => aiChatManager.runFormDraft(toolCallId, runForm))
const runnableKind = $derived(runForm.runnableKind ?? 'script')
const runnableNoun = $derived(runForm.cardNoun ?? runnableKind)
const staleFormToast = () =>
sendUserToast(`This run form is no longer active — ask again to run the ${runnableKind}.`, true)
sendUserToast(`This run form is no longer active — ask again to run the ${runnableNoun}.`, true)
const properties = $derived(draft.schema?.properties ?? {})
const hasArgs = $derived(Object.keys(properties).length > 0)
@@ -173,7 +174,7 @@
bind:args={draft.args}
/>
{:else}
<p class="text-xs text-secondary">This {runnableKind} takes no arguments.</p>
<p class="text-xs text-secondary">This {runnableNoun} takes no arguments.</p>
{/if}
</div>
</div>
@@ -210,7 +211,7 @@
{/if}
{#if runForm.resetKeys?.length}
<p class="text-2xs text-secondary">
Disabled by this {runnableKind}, so it will run with its default:
Disabled by this {runnableNoun}, so it will run with its default:
<span class="font-mono">{runForm.resetKeys.join(', ')}</span>
</p>
{/if}
@@ -75,6 +75,7 @@
? 'flow'
: 'script')
)
const runnableNoun = $derived(runForm?.cardNoun ?? runnableKind)
// Declining the form, stopping the turn and cancelling the job all land here, and none
// of them is a failure: the run stopped because someone said so.
const canceled = $derived(
@@ -198,8 +199,8 @@
)
const cancelReason = $derived(
ran
? `This run was cancelled while the ${runnableKind} was running.`
: `This run was cancelled before the ${runnableKind} started.`
? `This run was cancelled while the ${runnableNoun} was running.`
: `This run was cancelled before the ${runnableNoun} started.`
)
// Streaming opens the tab early: the result is already arriving, and one that appeared
// only at the end would hide the thing the user is waiting to read.
@@ -449,13 +450,16 @@
// A conversational flow's page has no run form, so opening it is all it can offer: the
// run stays on this card, where there is one to confirm.
const carries = $derived(asDeployedPage && !runForm?.conversational)
const deployedNoun = $derived(
runForm?.agentPage ? 'agent' : runnableKind === 'flow' ? 'flow' : 'script'
)
const previewTitle = $derived(
previewTarget === 'form'
? `Open this form in the preview panel: ${path}`
: previewTarget === 'deployed'
? carries
? `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and confirm this run there: ${path}`
: `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'}: ${path}`
? `Open the deployed ${deployedNoun} and confirm this run there: ${path}`
: `Open the deployed ${deployedNoun}: ${path}`
: aiChatManager.openRunInPreview
? `Open this run in the preview panel: ${path || runnableName}`
: `Open this run in a new tab: ${path || runnableName}`
@@ -470,7 +474,7 @@
if (previewTarget === 'deployed') {
aiChatManager.openRunOnDeployedPage?.({
toolCallId: message.tool_call_id,
kind: runnableKind === 'flow' ? 'flow' : 'script',
kind: deployedNoun,
path,
summary: label,
carries
@@ -758,7 +762,7 @@
unifiedSize="sm"
destructive
startIcon={{ icon: TimerOff }}
title={`Cancel the ${runnableKind}`}
title={`Cancel the ${runnableNoun}`}
onClick={() => aiChatManager.cancelJob(chatJob.jobId)}
>
Cancel
@@ -2,6 +2,7 @@
import { Button } from '$lib/components/common'
import {
ArrowRight,
Bot,
Boxes,
Calendar,
Database,
@@ -38,7 +39,7 @@
actions: ToolDisplayAction[]
}
type ActionCardKey = 'schedule' | 'resource' | 'variable' | CreatedResourceTriggerKind
type ActionCardKey = 'schedule' | 'resource' | 'agent' | 'variable' | CreatedResourceTriggerKind
type ActionCardConfig = {
title: string
icon: any
@@ -51,6 +52,7 @@
const actionCardConfigs: Record<ActionCardKey, ActionCardConfig> = {
schedule: { title: 'Schedule', icon: Calendar },
resource: { title: 'Resource', icon: Package },
agent: { title: 'Agent', icon: Bot },
variable: { title: 'Variable', icon: KeyRound },
http: { title: 'HTTP trigger', icon: Route },
websocket: { title: 'WebSocket trigger', icon: Unplug },
@@ -7,7 +7,7 @@
import { openItemPreviewAction, type PreviewCardKind } from './shared'
interface Props {
card: { kind: PreviewCardKind; path: string }
card: { kind: PreviewCardKind | 'agent'; path: string }
/** Opens something other than the item's own preview — the run card opens the call
* it owns, which is a form before it is a run. */
onOpen?: () => void
@@ -34,7 +34,18 @@
}
opening = true
try {
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path, mode))
// An agent is not a live editor the panel hosts: its editor opens as a page tab.
await runToolDisplayAction(
card.kind === 'agent'
? {
id: `open-agent:${card.path}`,
type: 'open_created_resource',
label: 'Open agent',
resource: 'agent',
path: card.path
}
: openItemPreviewAction(card.kind, card.path, mode)
)
} finally {
opening = false
}
@@ -515,6 +515,21 @@ describe('global AI tools', () => {
expect(result).not.toContain('Python Datatable API')
})
it('gives agent guidance only to a session that may also draft flows', async () => {
const resourcesOnly = await callGlobalTool(
'get_instructions',
{ subject: 'agent' },
toolCallbacks,
{ access: new Set(['write_resource_draft']) }
)
expect(resourcesOnly).not.toContain('Windmill agent authoring reference')
const builder = await callGlobalTool('get_instructions', { subject: 'agent' }, toolCallbacks, {
access: new Set(['write_resource_draft', 'write_flow_draft'])
})
expect(builder).toContain('Windmill agent authoring reference')
expect(builder).toContain('This user cannot write inline code')
})
it('returns only the requested language SDK for the datatable subject', async () => {
const ts = await callGlobalTool('get_instructions', { subject: 'datatable', language: 'bun' })
expect(ts).toContain('TypeScript Datatable API')
@@ -49,7 +49,18 @@ import type {
import { loadSchemaFromModule, memoryPropertyFor } from '$lib/components/flows/flowInfers'
import { linkedAgentStepSchema } from '$lib/components/flows/agentResourceUtils'
import { AGENT_FIELDS, initialVisibleAgentFields } from '$lib/components/flows/agentFormFields'
import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts'
import {
fetchAgentWithDraft,
inlineAgentDraft,
withAgentDrafts
} from '$lib/components/flows/linkedAgentDrafts'
import {
AGENT_CHAT_SCHEMA,
AGENT_STEP_ID,
agentChatFlow,
agentChatPath
} from '$lib/components/flows/conversations/agentEditorChat'
import type { AIAgentConfig } from '$lib/components/flows/agentResourceUtils'
import { resolveLinkedAgentTools } from '$lib/components/flows/flowState'
import { enabledToolNames, type AgentTool } from '$lib/components/flows/agentToolUtils'
import { evalValue } from '$lib/components/flows/utils.svelte'
@@ -139,6 +150,7 @@ import {
getPipelinePrompt,
getRawAppPrompt,
getResourcePrompt,
getAgentPrompt,
getScriptPrompt
} from '$system_prompts'
import type {
@@ -183,6 +195,8 @@ import { artifactTools } from '../artifacts/artifactTools'
import type { SessionArtifactsStore } from '../artifacts/artifactsState.svelte'
import type { PathRunType, Runnable } from '$lib/components/apps/inputType'
import { UserDraft } from '$lib/userDraft.svelte'
import { agentEditHref } from '../workspaceItems.svelte'
import { markAgentWritten } from '$lib/components/flows/agentEditorStore.svelte'
import { emptySchema } from '$lib/utils'
import type { Schema } from '$lib/common'
import { inferArgs } from '$lib/infer'
@@ -330,7 +344,8 @@ const INSTRUCTION_SUBJECTS = [
// `pipeline` likewise isn't an item type — a data pipeline is a set of
// annotated scripts in a folder, so it gets authoring guidance, not a CRUD type.
// `workspace_settings` is the settings document an admin reads and changes.
const INSTRUCTION_SUBJECTS_EXTRA = ['datatable', 'pipeline', 'workspace_settings'] as const
// `agent` is a resource of type `ai_agent`, with authoring guidance of its own.
const INSTRUCTION_SUBJECTS_EXTRA = ['agent', 'datatable', 'pipeline', 'workspace_settings'] as const
export const ALL_INSTRUCTION_SUBJECTS = [
...INSTRUCTION_SUBJECTS,
...INSTRUCTION_SUBJECTS_EXTRA
@@ -349,7 +364,7 @@ const ACTIVE_GLOBAL_EDITOR_DRAFTS: readonly {
]
export type GlobalActiveEditorContext = {
type: ActiveGlobalEditorType
type: ActiveGlobalEditorType | 'agent'
path: string
/** The key the draft is stored under, which `path` leaves behind on a rename. */
storagePath: string
@@ -401,7 +416,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum)
const getInstructionsSchema = z.object({
subject: instructionSubjectSchema.describe(
'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.'
'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.'
),
language: scriptLangSchema
.optional()
@@ -1066,6 +1081,45 @@ const runFlowToolDef = createToolDef(
{ strict: false }
)
const agentRunArgsSchema = z
.object({
user_message: z.string().describe('The message the agent answers.')
})
.passthrough()
.describe("The run's inputs: `user_message`, and `user_attachments` for files the user attaches.")
const testRunAgentSchema = z.object({
path: z.string().describe('Workspace path of the agent (`ai_agent` resource) to test.'),
args: agentRunArgsSchema,
memory_id: testRunFlowSchema.shape.memory_id.describe(
'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.'
),
background: backgroundArgSchema,
wait_seconds: waitSecondsArgSchema
})
const testRunAgentToolDef = createToolDef(
testRunAgentSchema,
'test_run_agent',
'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. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.',
{ strict: false }
)
const runAgentSchema = z.object({
path: z.string().describe('Workspace path of the deployed agent (`ai_agent` resource) to run.'),
args: agentRunArgsSchema,
memory_id: testRunAgentSchema.shape.memory_id,
background: backgroundArgSchema,
wait_seconds: waitSecondsArgSchema
})
const runAgentToolDef = createToolDef(
runAgentSchema,
'run_agent',
'Run a DEPLOYED AI agent for real, as its own page runs it. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. Use only when the user names the deployed version ("the deployed X", "in production", "for real").',
{ strict: false }
)
const testRunStepSchema = z.object({
path: z.string().describe('Workspace path of the flow containing the step to test.'),
stepId: z.string().describe('The id of the step/module to test.'),
@@ -1236,9 +1290,9 @@ const testRunAppRunnableToolDef = createToolDef(
const openPreviewSchema = z.object({
kind: z
.enum(['script', 'flow', 'raw_app', 'pipeline'])
.enum(['script', 'flow', 'raw_app', 'agent', 'pipeline'])
.describe(
'Item kind to preview. Use "raw_app" for code-based apps. Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
'Item kind to preview. Use "raw_app" for code-based apps and "agent" for a standalone AI agent (an `ai_agent` resource). Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
),
path: z
.string()
@@ -1256,9 +1310,9 @@ const openPreviewSchema = z.object({
* a workspace capability says nothing about `f/finance/*` — and is resolved in the
* handler instead. Mirrors `open_page` re-narrowing its page enum per user. */
const openPreviewNoPipelineKind = z
.enum(['script', 'flow', 'raw_app'])
.enum(['script', 'flow', 'raw_app', 'agent'])
.describe(
'Item kind to preview. Use "raw_app" for code-based apps. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
'Item kind to preview. Use "raw_app" for code-based apps and "agent" for a standalone AI agent (an `ai_agent` resource). The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
)
const openPreviewViewOnlySchema = openPreviewSchema.extend({
@@ -1287,7 +1341,7 @@ const openPreviewBuilderEditorSchema = (flows: boolean, apps: boolean) =>
})
const OPEN_PREVIEW_DESCRIPTION =
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, raw app or AI agent to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
const OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION =
'Show a workspace item in the side panel next to the chat, as its deployed page — the run form, triggers and past runs the user can act on. ONLY works inside an AI session. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). You cannot open item editors in this workspace, so use this to show the user something that is already deployed. Returns an error if there is no active session.'
@@ -1725,11 +1779,14 @@ ${groupMembershipRule}- Listings and reads return only what this user can see: i
- An agent, as the Agents page lists it, is a resource of type \`ai_agent\`: read it with read_workspace_item type "resource".${when(
// An agent draft is a resource draft that the server checks under the flow builder right.
canDraftResources && canWriteFlowDraft,
" You may draft a change to an agent, but never deploy or rename one here: the user reviews and deploys it from the agent's page, `/agents/edit/<path>`, so point them there."
` You may draft a new agent or a change to one, after calling get_instructions with subject "agent". The user can review it on the agent's page, ${external ? 'the url write_resource returns' : '\`/agents/edit/<path>\`'}.`
)}${when(
buildsFlows,
' Its tools, like flow steps, may only reference deployed scripts and flows by path: the server refuses inline code and hub scripts.'
)}${when(
canRunFlowPreview,
' Test an agent, its draft included, with test_run_agent.'
)} Run a deployed agent with run_agent, never with run_flow or run_script.${when(
!external,
`
- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".`
@@ -1802,7 +1859,7 @@ ${when(canCreateResourceType, `- create_resource_type creates a resource type th
? `${when(
!canWriteDraft,
`
- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${
- open_preview(kind, path) shows a deployed script / flow / app / agent in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${
buildsFlows && buildsApps
? 'The flow and app editors are the only editors you can open here: after writing or editing a flow or app draft, show it with open_preview(kind="flow" or "raw_app", path, mode="edit").'
: buildsFlows || buildsApps
@@ -1812,7 +1869,7 @@ ${when(canCreateResourceType, `- create_resource_type creates a resource type th
)}${when(
canWriteDraft,
`
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
- After writing or substantially editing a script / flow / app / agent draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it adds build_pipeline_node / edit_pipeline_node to your tools from the next step on — they are not in your tool list before open_preview returns, so call open_preview on its own, wait for its result, then build. They then stay available for the rest of the session even while another tab (a page, another preview) is shown: a call brings the pipeline editor back by itself, so do not re-open it first. Use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.`
)}
- When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app").
@@ -2892,6 +2949,7 @@ function getResourceInstructions(): string {
- For secret fields in a resource value, create the variable with \`write_variable\` and \`is_secret: true\`, and deploy it before the resource (see "Secrets" in the reference below).
- Reference formats inside resource values: \`$var:g/all/name\` (global), \`$var:u/user/name\` (user), \`$var:f/folder/name\` (folder). Reference another resource with \`$res:path/to/resource\`. The same strings are also how a resource or variable is passed as a run argument (see the run-argument rule in the resource reference below); what they are never valid as is a variable's own value.
- Use \`search_resource_types\` to discover valid \`resource_type\` names and their JSON Schemas. Match the resource value to that schema.
- For an agent (an \`ai_agent\` resource), call \`get_instructions\` with \`subject: "agent"\` instead.
- For OAuth resources, the \`is_oauth: true\` flag is managed by Windmill's OAuth flow; global mode generally creates manual resources, not OAuth ones.
# Windmill resource & variable reference
@@ -2899,6 +2957,38 @@ function getResourceInstructions(): string {
${getResourcePrompt()}`
}
async function getAgentInstructions(
workspace: string | undefined,
external: boolean,
/** The user cannot write inline code: the server refuses a rawscript tool from them. */
composeOnly: boolean,
/** The session cannot run `test_run_agent`. */
untestable: boolean
): Promise<string> {
const aiAgentProviders = external
? ''
: formatAiAgentProvidersPrompt(await getAiAgentProviderCatalog(workspace), {
canAskUser: true
})
const testRule = untestable
? ''
: '\n- Test the draft with `test_run_agent` before handing it over, and fix what the run shows: a test runs the draft as it stands, its tools included. Reuse one `memory_id` across calls to test follow-ups.'
return `# Global draft agent instructions
- An agent is drafted with \`write_resource\`: \`{ path, resource_type: "ai_agent", description?, value }\`, where \`value\` is the agent's configuration described below. To change an existing agent, read it with \`read_workspace_item\` type "resource" first and write back the whole value.
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` for a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist.${testRule}
- The user can review the agent on its page, ${external ? 'the url `write_resource` returns' : '`/agents/edit/<path>`'}. Deploy it with \`deploy_workspace_item\` (type "resource") once they ask, as for any other draft.
${
composeOnly
? '- This user cannot write inline code, so skip what the reference says about inline tools: every script tool points at a deployed script, found with the workspace search tools. When none does the job, say so and suggest asking a workspace admin for that script; never add a rawscript tool.'
: '- Before writing an inline tool, look for a deployed script that already does the job with the workspace search tools.'
}
${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''}
# Windmill agent authoring reference
${getAgentPrompt()}`
}
function getDatatableInstructions(language?: ScriptLang): string {
// Default to the TypeScript SDK so we return only what's needed, not both.
const lang = language ?? 'bun'
@@ -2923,7 +3013,9 @@ export function getInstructions(
language: ScriptLang | undefined,
workspace: string | undefined,
composeOnly: boolean,
external = false
external = false,
/** The session cannot run a flow preview, which an agent's test run is. */
untestable = false
): string | Promise<string> {
switch (subject) {
case 'script':
@@ -2932,6 +3024,8 @@ export function getInstructions(
return getFlowInstructions(workspace, composeOnly, external)
case 'resource':
return getResourceInstructions()
case 'agent':
return getAgentInstructions(workspace, external, composeOnly, untestable)
case 'app':
return getAppInstructions(language, composeOnly, external)
case 'datatable':
@@ -3603,7 +3697,7 @@ function buildOpenPageDefSchema(
}
const 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.'
'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.'
// Non-arg input the URL builder needs: the chat's operating workspace (the compare
// page cannot fall back to its own store default inside a session preview).
@@ -3887,14 +3981,37 @@ export const openPageTool: SessionTool<{}> = {
return previewResult
}
// A session's tab opens an agent in its own editor; the resources page would show it as JSON.
const agentPath =
page === 'resources' && parsed.open
? await ResourceService.getResource({
workspace: urlWorkspace,
path: parsed.open,
getDraft: true
})
.then((r) => (r.resource_type === 'ai_agent' ? parsed.open : undefined))
.catch(() => undefined)
: undefined
const linkUrl = agentPath ? agentEditHref(agentPath) : url
const linkLabel = agentPath ? 'Agent' : pageLabel
const linkSummary = agentPath ?? summary
// Outside a session there is no preview panel — offer a clickable link the user
// controls. (We deliberately don't navigate in place: a same-route goto would
// not re-sync the page's URL-driven filter state, so the change wouldn't land.)
toolCallbacks.setToolStatus(toolId, {
content: `Prepared a link to ${pageLabel}: ${summary}`,
actions: [{ id: `open-page:${page}:${url}`, type: 'navigate', label: summary, url, page }]
content: `Prepared a link to ${linkLabel}: ${linkSummary}`,
actions: [
{
id: `open-page:${page}:${linkUrl}`,
type: 'navigate',
label: linkSummary,
url: linkUrl,
page
}
]
})
return `Offered the user a link to the ${pageLabel} page (${summary}). They can click it to open.`
return `Offered the user a link to the ${linkLabel} page (${linkSummary}). They can click it to open.`
}
}
@@ -3906,7 +4023,7 @@ export const globalTools: SessionTool<{}>[] = [
def: createToolDef(
getInstructionsSchema,
'get_instructions',
'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.'
'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.'
),
planModeSafe: true,
fn: async (ctx) => {
@@ -3924,7 +4041,11 @@ export const globalTools: SessionTool<{}>[] = [
access.has('write_draft') ||
(parsed.subject === 'flow' && access.has('write_flow_draft')) ||
(parsed.subject === 'app' && access.has('write_app_draft')) ||
(parsed.subject === 'resource' && access.has('write_resource_draft'))
(parsed.subject === 'resource' && access.has('write_resource_draft')) ||
// The server checks an agent write under the flow builder right too.
(parsed.subject === 'agent' &&
access.has('write_resource_draft') &&
access.has('write_flow_draft'))
if (!canAuthor) {
const message =
'This session cannot create or edit this kind of item, so there is no authoring guidance to give for it. Tell the user plainly rather than describing how it would be done.'
@@ -3937,17 +4058,25 @@ export const globalTools: SessionTool<{}>[] = [
: parsed.subject
toolCallbacks.setToolStatus(toolId, { content: `Loaded ${label} instructions` })
// Guidance for inline code would steer a composing role at writes the server refuses
// and tools it was not given.
// and tools it was not given. An agent's test run is a flow preview, so its inline
// tools follow the flow rule.
const composeOnly =
!!access &&
!access.has(
parsed.subject === 'flow'
parsed.subject === 'flow' || parsed.subject === 'agent'
? 'write_inline_flow_code'
: parsed.subject === 'app'
? 'write_inline_app_code'
: 'write_draft'
)
return getInstructions(parsed.subject, parsed.language, ctx.workspace, composeOnly)
return getInstructions(
parsed.subject,
parsed.language,
ctx.workspace,
composeOnly,
false,
!!access && !access.has('run_flow_preview')
)
}
},
createSearchHubScriptsTool(false),
@@ -4528,6 +4657,36 @@ export const globalTools: SessionTool<{}>[] = [
showDetails: true,
autoCollapseDetails: false
},
{
requires: RUN_FLOW_PREVIEW,
def: testRunAgentToolDef,
fn: async (ctx) => {
const parsed = testRunAgentSchema.parse(ctx.args)
return testRunAgentByPath(parsed, ctx)
},
// No requiresConfirmation, for the reason test_run_script carries.
bypassedByAutoAccept: true,
streamingLabel: 'Preparing the test form...',
confirmationMessage: 'Run a test of an agent',
queuedLabel: (args) => `Test ${args?.path ?? 'the agent'}`,
showDetails: true,
autoCollapseDetails: false
},
{
requires: NONE,
def: runAgentToolDef,
readOnlySafe: true,
fn: async (ctx) => {
const parsed = runAgentSchema.parse(ctx.args)
return runDeployedAgent(parsed, ctx)
},
bypassedByAutoAccept: true,
streamingLabel: 'Preparing the run form...',
confirmationMessage: 'Run a deployed agent',
queuedLabel: (args) => `Run ${args?.path ?? 'an agent'}`,
showDetails: true,
autoCollapseDetails: false
},
{
requires: RUN_PREVIEW,
def: testRunStepToolDef,
@@ -5430,7 +5589,7 @@ function liveFlowTestHookFromCtx(
export type OpenPreviewHandler = (req: {
sessionId: string | undefined
kind: 'script' | 'flow' | 'raw_app' | 'pipeline'
kind: 'script' | 'flow' | 'raw_app' | 'agent' | 'pipeline'
path: string
/** Which side to open. Omitted means the editor, except for a pipeline, which has only
* one side. `openSessionPreview` resolves it before its capability gates rather than
@@ -5446,7 +5605,11 @@ export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined):
}
async function openSessionPreview(
args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string; mode?: 'edit' | 'view' },
args: {
kind: 'script' | 'flow' | 'raw_app' | 'agent' | 'pipeline'
path: string
mode?: 'edit' | 'view'
},
sessionId: string | undefined,
workspace: string | undefined,
access: SessionAccess | undefined
@@ -5461,6 +5624,18 @@ async function openSessionPreview(
}
return await openPreviewHandler({ ...args, mode: undefined, sessionId })
}
// An agent's editor drafts its resource, which the server checks under the flow builder right
// too; without both its deployed page is what the session can show. The editor itself turns
// read-only on an agent the user cannot write.
if (args.kind === 'agent') {
const canDraft =
!access || (access.has('write_resource_draft') && access.has('write_flow_draft'))
return await openPreviewHandler({
...args,
mode: args.mode === 'view' || !canDraft ? 'view' : 'edit',
sessionId
})
}
// Resolved before the gates, not after: the handler reads a missing mode as the editor, so
// gating on `args.mode === 'edit'` would wave through every call that simply omitted it —
// which is what a model whose schema only offers 'view' does.
@@ -5968,15 +6143,15 @@ const PREVIEW_CARD_KIND_BY_ITEM_KIND: Partial<
// `open_preview` takes), not its synthetic draft storage key.
function maybeAttachPreviewCard(
ctx: WriteDraftCtx,
itemKind: DraftPersistResult['itemKind'],
kind: PreviewCardKind | 'agent' | undefined,
path: string
): void {
): boolean {
// Write tools pass the raw tool ctx, whose session id lives in `helpers` —
// `ctx.sessionId` is only set by callers that thread it explicitly.
if (!ctx.sessionId && !sessionIdFromCtx(ctx)) return
const kind = PREVIEW_CARD_KIND_BY_ITEM_KIND[itemKind]
if (!kind) return
if (!ctx.sessionId && !sessionIdFromCtx(ctx)) return false
if (!kind) return false
ctx.toolCallbacks.setToolStatus(ctx.toolId, { previewCard: { kind, path } })
return true
}
// App write tools build varied success messages but share the same conflict /
@@ -5991,7 +6166,7 @@ function finishAppDraftWrite(
if (failure) return failure
if (codeDiff) ctx.toolCallbacks.setToolStatus(ctx.toolId, { codeDiff })
ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath)
maybeAttachPreviewCard(ctx, result.itemKind, result.item.path)
maybeAttachPreviewCard(ctx, PREVIEW_CARD_KIND_BY_ITEM_KIND[result.itemKind], result.item.path)
const { content, message, warning } = onSaved()
ctx.toolCallbacks.setToolStatus(ctx.toolId, { content, result: 'Saved as draft' })
return JSON.stringify({ success: true, message, warning }, null, 2)
@@ -6001,14 +6176,24 @@ function finishDraftWrite(
result: DraftPersistResult,
existed: boolean,
ctx: WriteDraftCtx,
codeDiff?: ToolCodeDiff
codeDiff?: ToolCodeDiff,
actions?: ToolDisplayAction[]
): string {
const failure = draftWriteFailure(result, ctx)
if (failure) return failure
if (codeDiff) ctx.toolCallbacks.setToolStatus(ctx.toolId, { codeDiff })
ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath)
maybeAttachPreviewCard(ctx, result.itemKind, result.item.path)
const stored = result.item
const isAgent =
stored.type === 'resource' &&
(stored.value as CreateResource | undefined)?.resource_type === 'ai_agent'
const card = maybeAttachPreviewCard(
ctx,
isAgent ? 'agent' : PREVIEW_CARD_KIND_BY_ITEM_KIND[result.itemKind],
stored.path
)
// Outside a session there is no preview chip, so the actions carry the way in.
if (actions && !card) ctx.toolCallbacks.setToolStatus(ctx.toolId, { actions })
const verb = existed ? 'Updated' : 'Created'
// Don't echo the flow value back: the model just sent it in the write call,
// so reflecting the (large) compact flow JSON only burns tokens. Variables
@@ -6021,7 +6206,7 @@ function finishDraftWrite(
: stored
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: `${verb} ${stored.type} "${stored.path}" as a draft`,
content: `${verb} ${isAgent ? 'agent' : stored.type} "${stored.path}" as a draft`,
result: `Saved as draft`
})
return JSON.stringify(
@@ -6055,6 +6240,7 @@ async function writeDraft<T, A>(
triggerKind?: TriggerKind
override?: boolean
codeDiff?: ToolCodeDiff | ((base: T | undefined, draft: T) => ToolCodeDiff | undefined)
actions?: ToolDisplayAction[]
} = {}
): Promise<string> {
const { workspace } = ctx
@@ -6075,7 +6261,7 @@ async function writeDraft<T, A>(
force: opts.override
})
const codeDiff = typeof opts.codeDiff === 'function' ? opts.codeDiff(base, draft) : opts.codeDiff
return finishDraftWrite(result, existed, ctx, codeDiff)
return finishDraftWrite(result, existed, ctx, codeDiff, opts.actions)
}
type ScriptDraftArgs = {
@@ -6283,7 +6469,11 @@ function writeResourceDraft(
args: CreateResource & { override?: boolean },
ctx: WriteDraftCtx
): Promise<string> {
return writeDraft(RESOURCE_SPEC, 'resource', args.path, args, ctx, { override: args.override })
return writeDraft(RESOURCE_SPEC, 'resource', args.path, args, ctx, {
override: args.override,
// The agent editor is where the user reviews and deploys the draft.
actions: args.resource_type === 'ai_agent' ? [createOpenAgentAction(args.path)] : undefined
})
}
const VARIABLE_SPEC: WriteSpec<VariableDraftState, WriteVariableArgs> = {
@@ -6790,6 +6980,10 @@ type FormRunSpec = {
autoAcceptable?: boolean
/** Whether this item's deployed page is a conversation rather than a run form. */
conversational?: boolean
/** The item is an agent, whose deployed page is the agent's rather than a flow's. */
agentPage?: boolean
/** What the card's prose calls the runnable, when `contextName` would name it wrongly. */
cardNoun?: string
background?: boolean
detachAfterMs?: number
startJob: (submitted: Record<string, any>) => Promise<string>
@@ -6885,6 +7079,8 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st
kind: spec.kind,
runnableKind: spec.contextName,
conversational: spec.conversational || undefined,
agentPage: spec.agentPage || undefined,
cardNoun: spec.cardNoun,
schema: autoAccepted ? undefined : schema,
code: autoAccepted ? undefined : spec.code,
lang: autoAccepted ? undefined : spec.lang,
@@ -7193,6 +7389,109 @@ async function testRunFlowByPath(
)
}
/** The agent at `path` as a run of it would use it: the user's draft when there is one, else the
* deployed resource. Refuses a path holding some other kind of resource. */
async function loadAgentForTest(
path: string,
workspace: string
): Promise<{ config: AIAgentConfig; description?: string }> {
// Settles a save the agent editor still has pending, or refuses: a test of the stored row would
// otherwise run an older configuration than the one on screen.
const { response, draft } = await fetchAgentWithDraft(path, workspace)
if (response.resource_type !== 'ai_agent') {
throw new Error(`"${path}" is a ${response.resource_type} resource, not an AI agent.`)
}
return draft
? { config: draft.args, description: draft.description }
: { config: response.value as AIAgentConfig, description: response.description }
}
async function testRunAgentByPath(
args: z.infer<typeof testRunAgentSchema>,
ctx: WriteDraftCtx
): Promise<string> {
const { workspace } = ctx
const { config, description } = await loadAgentForTest(args.path, workspace)
// Who the agent runs as and who may ask it in Slack, not inputs: a test runs as its editor.
const {
on_behalf_of: _obo,
run_on_behalf_of: _runObo,
slack_anyone: _slack,
...brain
} = config as AIAgentConfig & Record<string, unknown>
// The run the agent editor's chat makes of its draft.
const flow = agentChatFlow({
id: AGENT_STEP_ID,
value: inlineAgentDraft({ type: 'aiagent', tools: [], input_transforms: {} } as any, brain)
} as FlowModule)
return runThroughForm(
{
path: args.path,
schema: AGENT_CHAT_SCHEMA,
summary: description,
kind: 'test',
schemaNoun: 'agent',
toolName: 'test_run_agent',
proposed: args.args,
startMessage: `Starting agent test run for "${args.path}"...`,
contextName: 'flow',
noun: 'agent',
cardNoun: 'agent',
autoAcceptable: true,
background: args.background,
detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
startJob: (submitted) =>
JobService.runFlowPreview({
workspace,
memoryId: args.memory_id ?? randomUUID(),
requestBody: { path: agentChatPath(args.path), value: flow.value, args: submitted }
})
},
ctx
)
}
async function runDeployedAgent(
args: z.infer<typeof runAgentSchema>,
ctx: WriteDraftCtx
): Promise<string> {
const { workspace } = ctx
const agent = await ResourceService.getResource({ workspace, path: args.path })
if (agent.resource_type !== 'ai_agent') {
throw new Error(`"${args.path}" is a ${agent.resource_type} resource, not an AI agent.`)
}
return runThroughForm(
{
path: args.path,
schema: AGENT_CHAT_SCHEMA,
summary: agent.description,
kind: 'run',
// Its page is the agent's rather than a flow's, which is where the form moves. A turn of a
// conversation stays on the card: the page's run starts one of its own, without the memory id.
agentPage: true,
conversational: args.memory_id !== undefined,
schemaNoun: 'agent',
toolName: 'run_agent',
proposed: args.args,
startMessage: `Running agent "${args.path}"...`,
contextName: 'flow',
noun: 'agent',
cardNoun: 'agent',
autoAcceptable: true,
background: args.background,
detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
startJob: (submitted) =>
JobService.runAgent({
workspace,
path: args.path,
memoryId: args.memory_id,
requestBody: submitted as { user_message?: string }
})
},
ctx
)
}
async function testRunFlowStepByPath(
args: z.infer<typeof testRunStepSchema>,
ctx: WriteDraftCtx
@@ -7989,6 +8288,16 @@ function createOpenResourceAction(path: string): ToolDisplayAction {
}
}
function createOpenAgentAction(path: string): ToolDisplayAction {
return {
id: `open-agent:${path}`,
type: 'open_created_resource',
label: 'Open agent',
resource: 'agent',
path
}
}
function createOpenVariableAction(path: string): ToolDisplayAction {
return {
id: `open-deployed-variable:${path}`,
@@ -9216,12 +9525,14 @@ async function deployDraft(
}
case 'resource': {
let requestBody = draft.value as any
let isAgent = requestBody.resource_type === 'ai_agent'
if (await ResourceService.existsResource({ workspace, path })) {
// The server resets an agent's identity to whoever deploys, unless told to keep
// it: the identity is the deployed one, never the draft's.
const deployed = await ResourceService.getResource({ workspace, path })
const obo = (deployed.value as any)?.on_behalf_of
if (deployed.resource_type === 'ai_agent' && obo) {
isAgent = deployed.resource_type === 'ai_agent'
if (isAgent && obo) {
requestBody = {
...requestBody,
value: { ...requestBody.value, on_behalf_of: obo },
@@ -9232,7 +9543,8 @@ async function deployDraft(
} else {
await ResourceService.createResource({ workspace, requestBody })
}
actions = [createOpenResourceAction(path)]
if (isAgent) markAgentWritten(workspace, path)
actions = [isAgent ? createOpenAgentAction(path) : createOpenResourceAction(path)]
break
}
case 'variable': {
@@ -54,6 +54,16 @@ const DESCRIPTION_REWRITES: [tool: string, from: string, to: string][] = [
" Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the flow's own deployed page instead; either way this call waits for them and runs what they confirm.",
' It runs with exactly the `args` you send.'
],
[
'test_run_agent',
' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.',
' It runs with exactly the `args` you send.'
],
[
'run_agent',
' Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs.',
' It runs with exactly the `args` you send.'
],
['create_resource_type', ' once the user confirms the schema', ''],
['set_workspace_settings', ' The user reviews the changes before they apply.', '']
]
@@ -522,7 +522,8 @@ export type CreatedResourceAction = {
id: string
type: 'open_created_resource'
label: string
resource: 'schedule' | 'trigger' | 'resource' | 'variable'
/** `agent` is an `ai_agent` resource, opened on its own editor rather than as a resource. */
resource: 'schedule' | 'trigger' | 'resource' | 'agent' | 'variable'
path: string
targetKind?: 'script' | 'flow'
triggerKind?: CreatedResourceTriggerKind
@@ -576,6 +577,11 @@ export type RunFormDisplay = {
* flow. Its page can be opened to look at, but it has no form to confirm this call on, so
* the card keeps the run. */
conversational?: boolean
/** What runs is a deployed agent, whose own page takes the form over rather than a script's
* or a flow's. */
agentPage?: boolean
/** What the card's prose calls the runnable when `runnableKind` would name it wrongly. */
cardNoun?: string
/** Of whatever version is about to run: the deployed script, or the draft a test
* previews. Only the rendered form reads it, so it is dropped once one of the flags
* below unmounts that form: kept, every settled card would carry a copy of the schema
@@ -698,7 +704,7 @@ export type ToolDisplayMessage = {
/** Workspace item this tool created or updated. Rendered as a discrete,
* always-visible card that opens (or focuses) the item's preview in the
* session side panel. Set only for session chats — the side panel is their surface. */
previewCard?: { kind: PreviewCardKind; path: string }
previewCard?: { kind: PreviewCardKind | 'agent'; path: string }
/** Whole text on both sides of an edit this call saved. Kept in full rather than as a
* reference: drafts are overwritten in place, so the sides cannot be fetched back later. */
codeDiff?: ToolCodeDiff
@@ -31,6 +31,8 @@ export type WindmillItemKind =
| 'app'
| 'variable'
| 'resource'
/** An `ai_agent` resource, which has its own pages. */
| 'agent'
| 'schedule'
| 'http_trigger'
| 'websocket_trigger'
@@ -90,7 +92,10 @@ function workspaceItemTriggerKind(kind: WindmillItemKind): CreatedResourceAction
*/
export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string {
const raw =
(entry.draftOnly && draftEditHref(entry)) || offboardingItemHref(entry.kind, entry.path) || '#'
(entry.draftOnly && draftEditHref(entry)) ||
(entry.kind === 'agent' ? agentEditHref(entry.path) : undefined) ||
offboardingItemHref(entry.kind, entry.path) ||
'#'
if (!workspace) return raw
const hashIdx = raw.indexOf('#')
if (hashIdx === -1) {
@@ -102,6 +107,11 @@ export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string
return `${pathPart}${sep}workspace=${workspace}${hashPart}`
}
/** The agent editor, which shows the user's draft of the agent when there is one. */
export function agentEditHref(path: string): string {
return `/agents/edit/${path}`
}
function draftEditHref(entry: WorkspaceItemEntry): string | undefined {
switch (entry.kind) {
case 'script':
@@ -121,6 +131,7 @@ type WorkspaceItemListResult = Array<{
raw_app?: boolean | null
draft_only?: boolean | null
draft_path?: string | null
resource_type?: string
}>
const workspaceItemLoaders: Array<{
@@ -193,7 +204,7 @@ class WorkspaceItemRegistry {
for (const it of items) {
if (!map.has(it.path)) {
map.set(it.path, {
kind,
kind: kind === 'resource' && it.resource_type === 'ai_agent' ? 'agent' : kind,
path: it.path,
targetKind:
typeof it.is_flow === 'boolean' ? (it.is_flow ? 'flow' : 'script') : undefined,
@@ -292,7 +303,7 @@ export function workspaceItemAction(
path
}
if (kind === 'resource' || kind === 'variable') {
if (kind === 'resource' || kind === 'agent' || kind === 'variable') {
return { ...base, resource: kind }
}
@@ -27,7 +27,7 @@ vi.mock('$lib/gen', () => {
}
})
import { FlowService } from '$lib/gen'
import { FlowService, ResourceService } from '$lib/gen'
import {
extractCandidatePaths,
itemHref,
@@ -52,6 +52,18 @@ describe('workspaceItemRegistry', () => {
expect(entry).toMatchObject({ kind: 'flow', path: 'u/me/draft_1234', draftOnly: true })
expect(itemHref(entry!, 'ws-draft')).toBe('/flows/edit/u/me/draft_1234?workspace=ws-draft')
})
it('links an AI agent to its own editor, not to the resources page', async () => {
vi.mocked(ResourceService.listResource).mockResolvedValueOnce([
{ path: 'u/me/helper', resource_type: 'ai_agent' },
{ path: 'u/me/db', resource_type: 'postgresql' }
] as any)
await workspaceItemRegistry.ensureLoaded('ws-agent')
const agent = workspaceItemRegistry.resolve('ws-agent', 'u/me/helper')!
expect(itemHref(agent, 'ws-agent')).toBe('/agents/edit/u/me/helper?workspace=ws-agent')
expect(workspaceItemAction(agent.kind, agent.path)).toMatchObject({ resource: 'agent' })
expect(workspaceItemRegistry.resolve('ws-agent', 'u/me/db')?.kind).toBe('resource')
})
})
describe('WINDMILL_PATH_REGEX', () => {
@@ -115,12 +115,18 @@
// The session's preview opens on this same deployed page (`/get/`), not the item's editor:
// someone here came to run the item, not to change it.
const sessionPage = $derived(
!path
? undefined
: errorHandlerKind
? `/${errorHandlerKind}s/get/${path}`
: itemKind === 'agent'
? `/agents/get/${path}`
: undefined
)
const sessionSource: OpenInSessionSource | undefined = $derived(
errorHandlerKind && path
? {
page: () => `/${errorHandlerKind}s/get/${path}`,
workspaceId: $operatingWorkspace ?? undefined
}
sessionPage
? { page: () => sessionPage, workspaceId: $operatingWorkspace ?? undefined }
: undefined
)
@@ -1,7 +1,7 @@
<script lang="ts">
import { Drawer, DrawerContent } from '$lib/components/common'
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
import { workspaceStore } from '$lib/stores'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { markAgentWritten } from './agentEditorStore.svelte'
interface Props {
@@ -12,6 +12,9 @@
let { path, canRestore, onRestored }: Props = $props()
let drawer: Drawer | undefined = $state()
// The workspace the history reads and restores in, which a session's fork makes other than the
// navigation one.
const operatingWorkspace = useOperatingWorkspace()
export function open() {
drawer?.openDrawer()
@@ -26,7 +29,7 @@
onRestore={() => {
drawer?.closeDrawer()
// A restore writes the resource as a deploy does, so whatever shows this agent re-reads it.
markAgentWritten($workspaceStore ?? undefined, path)
markAgentWritten($operatingWorkspace ?? undefined, path)
onRestored?.()
}}
/>
@@ -431,6 +431,15 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
seedNewUntilInput = { ws, path }
return
}
// A view of what is deployed finding nothing there is that view's ordinary state for a
// draft-only agent, not a failure: it says so rather than refusing with a toast.
if (opts.deployedOnly?.() && (err as { status?: number })?.status === 404) {
loading = false
state = undefined
deployed = undefined
noDeployed = true
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.
@@ -0,0 +1,436 @@
<script lang="ts">
import { untrack } from 'svelte'
import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { resource } from 'runed'
import { goto } from '$lib/navigation'
import { copilotInfo } from '$lib/aiStore'
import AgentEvalsModal from '$lib/components/flows/content/AgentEvalsModal.svelte'
import AgentUsePanel from '$lib/components/flows/content/AgentUsePanel.svelte'
import { SlackAgentsService } from '$lib/gen'
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte'
import { Alert, Tab, Tabs } from '$lib/components/common'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
import AgentHistory from '$lib/components/flows/AgentHistory.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { recentItems } from '$lib/components/home/recentItems.svelte'
import { useEditRights } from '$lib/operatorWriteRights'
import { sendUserToast } from '$lib/toast'
import { PLAN_MODE_MESSAGES } from '$lib/components/copilot/chat/planModeMessages'
import type { PendingRun } from '$lib/components/details/pendingRun'
import {
setOperatingWorkspace,
useOperatingUser
} from '$lib/components/operatingWorkspace.svelte'
/**
* The deployed agent, as a flow's or a script's page shows theirs: a way to run it, a chat when
* its memory keeps the conversation and the inputs form otherwise, beside what it is.
*/
interface Props {
path: string
workspace: string | undefined
/** Rendered inside another page (a session tab) rather than as the route: the header stays
* in place instead of taking the page header, and every link goes through `onNavigate`. */
embedded?: boolean
/** Whether this page is the one on screen, so its keyboard shortcut is not answered by a
* hidden tab. */
active?: boolean
onNavigate?: (url: string) => void | Promise<void>
/** A chat call waiting on a run of this agent: the form opens on what it proposed, and the
* run started here answers it. */
pendingRun?: PendingRun
/** Whether the agent is deployed, once that is known. */
onLoadState?: (state: 'loaded' | 'not_found') => void
/** The deployed agent's summary, for a host that names the page after it. */
onSummary?: (summary: string | undefined) => void
}
let {
path,
workspace: ws,
embedded = false,
active = true,
onNavigate = (url) => goto(url),
pendingRun = undefined,
onLoadState = undefined,
onSummary = undefined
}: Props = $props()
// Every workspace-scoped call below and in the drawers this page opens (the summary's save,
// permissions, history) targets `workspace`, not the nav store: a session shows the agent of its
// own workspace, which a fork makes a different one.
setOperatingWorkspace(() => ws)
// The user as this workspace knows them: in a fork, the navigation user is the parent's.
const operatingUser = useOperatingUser()
/** Bumped to read the agent again after it was renamed in place. */
let reloaded = $state(0)
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let agent = $derived(host?.draftHandle())
let testPane = $derived(host?.testPaneHandle())
let config = $derived(agent?.state?.args)
let chatAvailable = $derived(keepsManagedMemory(config?.memory))
const editRights = useEditRights(() => ws)
let canAuthor = $derived(editRights.roleCanAuthor('agent'))
let canCode = $derived(editRights.capabilities.code)
let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && canAuthor)
// Recorded once the agent is in: a path with nothing deployed never loads one. Not from a
// session's tab, as a script's page in one records nothing either.
let loaded = $derived(config != undefined)
$effect(() => {
if (ws && loaded && !embedded) {
recentItems.record(ws, {
kind: 'agent',
path,
summary: agent?.state?.description,
via: 'view'
})
}
})
// Reported once loaded: a host takes the first report as the name for good, an empty one included.
$effect(() => {
if (!config) return
const summary = agent?.state?.description?.trim() || undefined
untrack(() => onSummary?.(summary))
})
$effect(() => {
if (!agent || agent.loading) return
const state = agent.noDeployed ? 'not_found' : loaded ? 'loaded' : undefined
if (state) untrack(() => onLoadState?.(state))
})
// A call waiting on a run is answered from the form, so it opens there even for an agent that
// would otherwise open on its chat.
let showedPendingForm = false
$effect(() => {
if (!pendingRun || showedPendingForm || !testPane?.mode) return
showedPendingForm = true
untrack(() => {
if (testPane) testPane.mode = 'form'
})
})
let args = $state<Record<string, any>>(
untrack(() => (pendingRun ? { ...pendingRun.proposedArgs } : {}))
)
let configModal: AgentConfigModal | undefined = $state(undefined)
let shareModal: ShareModal | undefined = $state(undefined)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
let history: AgentHistory | undefined = $state(undefined)
let deleteOpen = $state(false)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
let clientWidth = $state(window.innerWidth)
let pickedMobileTab = $state('agent')
// The page is reused across agents, and an agent without managed memory has no Slack tab.
let mobileTab = $derived(pickedMobileTab === 'slack' && !chatAvailable ? 'api' : pickedMobileTab)
let wide = $derived(clientWidth >= 1024)
const slack = resource(
() => ({ workspace: ws, path }),
async ({ workspace, path }) =>
workspace ? await SlackAgentsService.getAgentSlackChannels({ workspace, path }) : undefined
)
let runForm: RunForm | undefined = $state(undefined)
let isValid = $state(true)
// Cmd/Ctrl+Enter runs the form, as on a script's or a flow's page. The form stays mounted, hidden,
// while the chat shows, and the chat's composer owns the key then.
function onKeyDown(event: KeyboardEvent) {
if (!active) return
if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return
if (!runForm || testPane?.mode === 'chat' || (!wide && mobileTab !== 'agent')) return
if (isValid) {
event.preventDefault()
runForm.run()
} else {
sendUserToast('Please fix errors before running', true)
}
}
// The call a press answers is the one it was pressed under: one settled meanwhile — declined,
// then another proposed for this same agent — must not be answered with a job it never asked for.
let pressed: { call: PendingRun | undefined; schema: Record<string, any> } | undefined
function runStarted(jobId: string, runArgs: Record<string, any>) {
const at = pressed
pressed = undefined
// The call follows the job and shows it in place of this page, so opening the run here as
// well would leave two surfaces on one job.
if (at?.call?.adopt(jobId, runArgs, at.schema)) return
void onNavigate(`/run/${jobId}?workspace=${ws}`)
}
async function remove() {
if (!ws || !(await deleteAgent(ws, path))) return
// Embedded, the host owns the tab: read again so it shows that nothing is deployed here,
// rather than opening Home beside a tab still showing the deleted agent.
if (embedded) reloaded++
else await onNavigate('/?kind=agent')
}
// The workspace's, so a path change never waits on it and a menu never outlives its path.
const deployUiSettings = resource(
() => ws,
() => getDeployUiSettings()
)
// A role that cannot build agents gets no menu, as on a flow's page.
let menuItems = $derived(
config != undefined && canAuthor
? agentMenuItems({
path,
canWrite: canEdit,
noCode: !canCode,
wsSpecific: agent?.state?.wsSpecific,
deployUiSettings: deployUiSettings.current,
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
onHistory: () => history?.open(),
onDelete: () => (deleteOpen = true)
}).map((item) => ({
label: item.displayName,
Icon: item.icon,
onclick: (e: MouseEvent) => item.action?.(e),
disabled: item.disabled,
color: item.type === 'delete' ? ('red' as const) : undefined
}))
: []
)
</script>
<ShareModal bind:this={shareModal} />
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<AgentHistory bind:this={history} {path} canRestore={canEdit} onRestored={() => reloaded++} />
<ConfirmationModal
open={deleteOpen}
title="Delete agent"
confirmationText="Delete"
on:canceled={() => (deleteOpen = false)}
on:confirmed={() => {
deleteOpen = false
remove()
}}
>
<span>Every flow that links {path} will fail at its agent step once it is deleted.</span>
</ConfirmationModal>
<svelte:window onkeydown={onKeyDown} />
<main class="h-full w-full flex flex-col">
<!-- `labels`: the pen saves every field it shows in one write, so the labels it shows have to be
the ones the resource carries. Handed none, it would offer an empty list, and the first
label added there would replace the agent's own. -->
<DetailPageHeader
ownsPageHeader={!embedded}
itemKind="agent"
summary={config ? agent?.state?.description : undefined}
labels={agent?.state?.labels}
{path}
{menuItems}
onSaved={canEdit
? async (newPath) => {
// A rename moves the page; a summary saved in place has to be read again, since
// what is shown here came from the load this page keys on.
if (newPath !== path) await onNavigate(`/agents/get/${newPath}`)
else reloaded++
}
: undefined}
mainButtons={[
// Evaluating an agent builds datasets and runs against it: authoring, as editing is, and
// eval datasets are refused to a role without `code` whatever its flows right.
...(canEdit && canCode
? [
{
label: 'Evals',
buttonProps: {
variant: 'default',
unifiedSize: 'md',
startIcon: FlaskConical,
title: 'Run this agent against a dataset of cases',
onClick: () => evalsModal?.openModal()
}
}
]
: []),
...(canEdit
? [
{
label: 'Edit',
buttonProps: {
variant: 'accent',
unifiedSize: 'md',
startIcon: Pen,
href: `/agents/edit/${path}`
}
}
]
: [])
]}
>
<OnBehalfOfBadge
onBehalfOf={config?.run_on_behalf_of ? agent?.onBehalfOf : undefined}
onBehalfOfEmail={undefined}
kind="agent"
/>
{#snippet leading_actions()}
<!-- Only an agent that keeps the conversation can chat: without managed memory every
message would be answered alone, so it is run from its inputs with nothing to switch. -->
{#if chatAvailable && testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="md"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="md"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{/snippet}
</DetailPageHeader>
<!-- Below `lg` the split would squeeze the chat, so the panel's sections become tabs of the
page, as on a script's or a flow's page. They cover the agent rather than replacing it:
unmounting the host destroys the chat and any turn in flight, so the agent pane keeps one
parent at every width. -->
{#if !wide && ws && config}
<Tabs
bind:selected={() => mobileTab, (t) => (pickedMobileTab = t)}
wrapperClass="flex-none no-scrollbar"
>
<Tab value="agent" label={testPane?.mode === 'chat' ? 'Chat' : 'Run form'} />
<Tab value="api" label="API" />
{#if chatAvailable}
<Tab value="slack" label="Slack" />
{/if}
</Tabs>
{/if}
<div class="relative flex-1 min-h-0" bind:clientWidth>
<Splitpanes>
<Pane size={wide ? 65 : 100} minSize={50}>
<div class="h-full" inert={!wide && mobileTab !== 'agent'}>
{@render agentPane()}
</div>
</Pane>
{#if wide && ws && config}
<Pane size={35} minSize={15}>
{@render usePane(ws)}
</Pane>
{/if}
</Splitpanes>
{#if !wide && ws && config}
<div class={['absolute inset-0 z-10 bg-surface', mobileTab === 'agent' && 'hidden']}>
{@render usePane(ws, mobileTab === 'slack' ? 'slack' : 'api')}
</div>
{/if}
</div>
</main>
{#snippet agentPane()}
{#if agent?.noDeployed && !onLoadState}
<div class="p-8 text-center text-sm text-secondary">
Nothing is deployed at <span class="font-mono">{path}</span> yet.
</div>
{/if}
{#key `${ws}:${path}:${reloaded}`}
<AgentEditorHost
bind:this={host}
{path}
workspace={ws}
enableAi={$copilotInfo.enabled}
view
onOpenConfig={() => configModal?.open()}
onRunStarted={embedded || pendingRun ? runStarted : undefined}
>
{#snippet viewForm({ schema, run, loading, actions })}
{#if config && !chatAvailable}
<Alert type="info" size="xs" title="Managed memory is off" class="mb-4">
Each run is answered on its own inputs, so this agent has no chat. That suits reusing it
as a step in a flow.{canEdit
? ' Turn on managed memory in its configuration to chat with it.'
: ''}
</Alert>
{/if}
{#if pendingRun}
<div class="mb-2">
<InputSelectedBadge inputSelected="pending_run" onReject={() => pendingRun.decline()} />
</div>
{/if}
<RunForm
runnable={{ schema, path }}
runAction={(scheduledFor, runArgs) => {
pressed = { call: pendingRun, schema }
return run(scheduledFor, runArgs)
}}
runRefusal={pendingRun?.planModeActive ? PLAN_MODE_MESSAGES.runFormRefused : undefined}
schedulable={false}
syncArgsToUrl={!embedded}
detailed={false}
autofocus
{loading}
{actions}
bind:args
bind:isValid
bind:this={runForm}
/>
{/snippet}
</AgentEditorHost>
{/key}
{/snippet}
{#snippet usePane(ws: string, section?: 'api' | 'slack')}
<AgentUsePanel
agentPath={path}
workspace={ws}
isAdmin={!!(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)}
chat={chatAvailable}
slackLoading={slack.loading && !slack.current}
slackTeamName={slack.current?.slack_team_name}
botName={slack.current?.bot_name}
missingScopes={slack.current?.missing_scopes ?? []}
signingSecretSet={slack.current?.signing_secret_set ?? true}
runsAs={slack.current?.runs_as}
anyone={slack.current?.anyone ?? false}
canShare={slack.current?.can_share ?? false}
channels={slack.current?.channels ?? []}
{section}
/>
{/snippet}
{#if ws}
<AgentEvalsModal bind:this={evalsModal} agentPath={path} workspace={ws} />
{/if}
{#if config}
<AgentConfigModal bind:this={configModal} {config} toolSchema={(id) => host?.toolSchema(id)} />
{/if}
@@ -3,7 +3,7 @@
import { writable } from 'svelte/store'
import { MessageCircleOff, SlidersHorizontal } from 'lucide-svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import { Alert, Button } from '$lib/components/common'
import { Alert, Button, Tab, Tabs } from '$lib/components/common'
import FlowChat from '../conversations/FlowChat.svelte'
import {
AGENT_CHAT_SCHEMA,
@@ -71,6 +71,9 @@
view?: boolean
/** Opens the agent's configuration, offered beside the model it runs with. */
onOpenConfig?: () => void
/** Takes a view's run once it has started, instead of opening its run page: for a host that
* is not the app's top-level route (a session tab), or a call waiting on that run. */
onRunStarted?: (jobId: string, args: Record<string, any>) => void
/** A view's form mode: the run form, drawn by the page. Kept out of this component because
* the flow editor reaches it, and the run form reads the navigation workspace. */
viewForm?: import('svelte').Snippet<
@@ -95,6 +98,7 @@
isNew = false,
view = false,
onOpenConfig = undefined,
onRunStarted = undefined,
viewForm = undefined
}: Props = $props()
@@ -272,6 +276,13 @@
if (mode === 'chat') chatMounted = true
}
// Too narrow for the two panes side by side (a session's side panel), they take turns as tabs:
// each needs about 400px, the test pane's floor in the script editor too. Both stay mounted,
// sized to nothing when hidden: unmounting the test pane would end its chat.
let editorWidth = $state(0)
let narrow = $derived(editorWidth > 0 && editorWidth < 900)
let narrowTab = $state<'config' | 'test'>('config')
// draft.args -> module. Only when the draft moved on its own (a load, an external write, a
// discard); an edit made in the form arrives here as its own echo and is skipped.
$effect(() => {
@@ -407,7 +418,8 @@
runLoading = true
try {
const id = await JobService.runAgent({ workspace, path, requestBody: args })
await goto(`/run/${id}?workspace=${workspace}`)
if (onRunStarted) onRunStarted(id, args)
else await goto(`/run/${id}?workspace=${workspace}`)
} finally {
runLoading = false
}
@@ -509,7 +521,9 @@
return testMode
},
set mode(mode: 'form' | 'chat' | undefined) {
if (mode) showTestMode(mode)
if (!mode) return
showTestMode(mode)
narrowTab = 'test'
}
}
}
@@ -567,16 +581,32 @@
{:else if agentModule && agentValue}
<!-- Named and positioned so the tool picker's popover can portal here: the `#flow-editor` it
otherwise targets is behind this dialog, and does not exist at all on the resources page. -->
<div id="agent-editor" class="relative h-full min-h-0">
<!-- The editor's hosts leave the horizontal padding to it: side by side the panes sit inside
it, and as tabs the strip runs edge to edge while each pane keeps its own. -->
<div
id="agent-editor"
class="relative h-full min-h-0 flex flex-col {!view && !narrow ? 'px-4 sm:px-6' : ''}"
bind:clientWidth={editorWidth}
>
{#if view}
<!-- A view only runs the agent: the page beside it summarizes the configuration. -->
{@render runPane()}
{:else}
{#if narrow}
<Tabs bind:selected={narrowTab} class="shrink-0">
<Tab value="config" label="Configuration" />
<Tab value="test" label="Test" />
</Tabs>
{/if}
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
answer want opposite splits, and only the reader knows which they are on. -->
<Splitpanes class="h-full">
<Pane size={55} minSize={30}>{@render configPane()}</Pane>
<Pane size={45} minSize={20}>{@render runPane()}</Pane>
<Splitpanes class="flex-1 min-h-0 {narrow ? '[&>.splitpanes__splitter]:!hidden' : ''}">
<Pane size={narrow ? (narrowTab === 'config' ? 100 : 0) : 55} minSize={narrow ? 0 : 30}>
{@render configPane()}
</Pane>
<Pane size={narrow ? (narrowTab === 'test' ? 100 : 0) : 45} minSize={narrow ? 0 : 20}>
{@render runPane()}
</Pane>
</Splitpanes>
{/if}
</div>
@@ -9,7 +9,10 @@
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
import { clearPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession'
import {
clearPageDrawerAnchor,
persistDraftForSession
} from '$lib/components/sessions/pageDrawerSession'
import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte'
import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils'
@@ -36,6 +39,11 @@
import { emptyString } from '$lib/utils'
import Tooltip from '$lib/components/Tooltip.svelte'
import { useEditRights } from '$lib/operatorWriteRights'
import { itemDisplayName } from '$lib/components/sessions/previewRouter'
import { UserDraft } from '$lib/userDraft.svelte'
import OpenInSessionButton, {
type OpenInSessionSource
} from '$lib/components/sessions/OpenInSessionButton.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -50,8 +58,10 @@
/** A deploy moved the agent from `from` to `to`. What names the old path belongs to the surface
* that opened the editor: a flow's own steps, a page's URL. */
onRenamed?: (from: string, to: string) => void
/** `page` fills its container with the editor's header and body instead of opening a dialog. */
layout?: 'modal' | 'page'
/** `page` fills its container with the editor's header and body instead of opening a dialog,
* the header going to the route's page header; `pane` draws that header itself, for a host
* nested in another page (a session tab) whose page header is not this editor's. */
layout?: 'modal' | 'page' | 'pane'
/** Where closing leads, for a layout that has no dialog to dismiss. `deployed` is false for an
* agent that exists only as a draft, which has no page of its own to return to. */
onClose?: (deployed: boolean) => void
@@ -59,6 +69,13 @@
onDeployed?: (path: string) => void
/** Open on the settings, as a link to them does. */
startInSettings?: boolean
/** The agent this mount shows, held here rather than in the editor every other mount shares,
* for a host that shows several at once (a session's tabs). Navigating and closing then stay
* with this mount, and `owns` is not consulted. */
fixedTarget?: AgentEditorTarget
/** The agent's name as it stands in the editor, edits included, for a host that names it: its
* summary, or for a never-deployed agent parked at `draft_<uuid>`, the path it will deploy to. */
onSummary?: (summary: string | undefined) => void
}
let {
@@ -68,17 +85,29 @@
layout = 'modal',
onClose = undefined,
onDeployed = undefined,
startInSettings = false
startInSettings = false,
fixedTarget = undefined,
onSummary = undefined
}: Props = $props()
// Every target names the surface that opened it, and only a flow step or a resource row can:
// an agent used as a tool of the agent being edited stays part of it, with no way in this editor
// to link it to a saved agent of its own. So `owns` decides alone — nothing here has to adopt a
// target opened from inside another editor.
let local = $state<AgentEditorTarget | undefined>(untrack(() => fixedTarget))
let target = $derived.by(() => {
if (local) return local
const t = agentEditorTarget()
return t && owns(t) ? t : undefined
})
function showTool(toolId: string | undefined) {
if (local) local = { ...local, toolId, view: undefined }
else showAgentEditorTool(toolId)
}
function showView(view: AgentEditorTarget['view']) {
if (local) local = { ...local, view, toolId: undefined }
else showAgentEditorView(view)
}
// The last agent this dialog showed. The version pane keeps working after `onRestore` returns —
// it reloads its list keyed on the path it was given — while the restore handler has already
// closed the dialog and dropped the target. Reading the live one there throws, and the throw
@@ -90,6 +119,23 @@
})
let ws = $derived(target?.workspace ?? $operatingWorkspace)
// On its own route, the hand-off the script and flow editors have: a session on this agent's
// editor tab, opened once what is on screen is saved.
let sessionSource = $derived.by((): OpenInSessionSource | undefined => {
const path = target?.path
const workspace = ws
if (layout !== 'page' || !path || !workspace) return undefined
return {
page: () => `${RESOURCES_PATH}#/resource/${path}`,
workspaceId: workspace,
beforeOpen: async () => {
await persistDraftForSession(workspace, 'resource', path)
// A new agent has no draft row until an edit autosaves one, and the session reads the
// agent from the server. Only for one never deployed, which has no baseline to skip.
if (draftOnly) await UserDraft.forcePersist('resource', path, { workspace })
}
}
})
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let versionDrawer: Drawer | undefined = $state(undefined)
let settingsDrawer: Drawer | undefined = $state(undefined)
@@ -151,6 +197,13 @@
// stored at a minted `draft_<uuid>` path and named by the path its first deploy will create,
// as the home list names it. Falls back to the target while the draft is still loading.
let shownPath = $derived(draft?.state?.path || target?.path)
// Reported once loaded, then on every rename: from the first report on, the host names the tab by
// it rather than by its listing, an empty name included.
$effect(() => {
if (!draft?.state) return
const name = itemDisplayName(target?.path ?? '', draft.state.path, draft.state.description)
untrack(() => onSummary?.(name))
})
// Where the evals pane is within itself, so its levels extend this dialog's trail rather than
// opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on
@@ -166,7 +219,7 @@
let root = $derived<ModalTrailSegment>({
label: shownPath ?? 'Agent',
onclick: inEvals || inSettings ? () => showAgentEditorView(undefined) : undefined
onclick: inEvals || inSettings ? () => showView(undefined) : undefined
})
let trail = $derived<ModalTrailSegment[]>(
inEvals
@@ -185,12 +238,12 @@
// The dialog's levels are its pages; a page has none, so evals open over it as a dialog.
function openEvals() {
if (layout === 'modal') showAgentEditorView('evals')
if (layout === 'modal') showView('evals')
else evalsModal?.openModal()
}
function openSettings() {
if (layout === 'modal') showAgentEditorView('settings')
if (layout === 'modal') showView('settings')
else settingsDrawer?.openDrawer()
}
@@ -219,7 +272,7 @@
function close() {
// Unknown until the load answers, and a detail page that does not exist is the worse guess.
const deployed = draft != undefined && !draft.loading && !draftOnly && !refused
closeAgentEditor()
if (!local) closeAgentEditor()
if (layout === 'modal') void clearPageDrawerAnchor(RESOURCES_PATH)
onClose?.(deployed)
}
@@ -229,6 +282,7 @@
// way back and tells the flow editor a dialog is over its graph. Only its own, so a mount going
// down never drops a target another one is showing.
onDestroy(() => {
if (local) return
const t = agentEditorTarget()
if (t && owns(t)) closeAgentEditor()
})
@@ -298,7 +352,9 @@
// The dialog is keyed on the path, so this reloads it on the renamed agent. Only while it
// still shows the one deployed: it can be closed or pointed elsewhere mid-request.
if (target?.path === at.path) {
openAgentEditor({ path: savedPath, workspace: target.workspace, host: target.host })
const next = { path: savedPath, workspace: target.workspace, host: target.host }
if (local) local = next
else openAgentEditor(next)
}
}
await reconciled
@@ -331,6 +387,19 @@
>
{@render body()}
</Modal>
{:else if layout === 'pane'}
<div class="h-full min-h-0 flex flex-col">
<div class="flex items-center gap-2 px-4 sm:px-6 py-2 border-b min-w-0 shrink-0">
<div class="min-w-0 flex-1 flex items-center gap-1">
{@render agentSummary()}
{@render agentHint()}
</div>
{@render settings()}
</div>
<div class="flex-1 min-h-0 px-4 sm:px-6">
{@render body()}
</div>
</div>
{:else}
<!-- On its own route the editor's header is the page header: the agent's path is the
breadcrumb, the level it is on reads after that name, and its controls are the band's
@@ -346,6 +415,7 @@
}}
afterName={agentHint}
actions={settings}
{sessionSource}
separator="always"
/>
<div class="h-full min-h-0 flex flex-col">
@@ -533,6 +603,7 @@
title="Version history"
on:click={() => versionDrawer?.openDrawer()}
/>
<OpenInSessionButton source={sessionSource} />
<Button
unifiedSize="sm"
variant="accent"
@@ -566,9 +637,11 @@
them for its own levels. Warmed, as those levels are: a page built on its first visit
lands inside the transition and arrives empty. The page layout has no levels: it opens
settings in a drawer and evals in a dialog, so warming them there would build them twice. -->
<!-- Full-bleed as the banner is, each page padding itself: the editor drops its padding
when its panes become tabs, and this clips anything that reaches past it. -->
<PagedContent
warm
class="flex-1 min-h-0"
class="flex-1 min-h-0 -mx-4 sm:-mx-6"
current={inEvals ? 'evals' : inSettings ? 'settings' : 'agent'}
pages={[
{ key: 'agent', content: agentPage },
@@ -589,7 +662,7 @@
workspace={ws}
{enableAi}
toolId={target?.toolId}
onSelectTool={(id) => showAgentEditorTool(id)}
onSelectTool={(id) => showTool(id)}
{onSaved}
isNew={target?.isNew}
/>
@@ -608,20 +681,22 @@
{/snippet}
{#snippet settingsPage()}
<div class="max-w-2xl py-6">{@render settingsFields()}</div>
<div class="max-w-2xl py-6 px-4 sm:px-6">{@render settingsFields()}</div>
{/snippet}
{#snippet evalsPage()}
<EvalsPane
agentPath={target?.path ?? ''}
opWorkspace={ws}
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
bind:location={evalsLocation}
active={inEvals}
/>
<div class="h-full min-h-0 px-4 sm:px-6">
<EvalsPane
agentPath={target?.path ?? ''}
opWorkspace={ws}
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
bind:location={evalsLocation}
active={inEvals}
/>
</div>
{/snippet}
{#if layout === 'page' && ws}
{#if layout !== 'modal' && ws}
<AgentEvalsModal
bind:this={evalsModal}
agentPath={target?.path ?? ''}
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import type { FlowModule } from '$lib/gen'
import { agentChatFlow, agentChatGap, agentChatPath, agentStepLabel } from './agentEditorChat'
import {
agentChatFlow,
agentChatGap,
agentChatPath,
agentRunHref,
agentStepLabel
} from './agentEditorChat'
describe('agentChatPath', () => {
it('is a path no flow can take, so a same-path flow never shares its conversations', () => {
@@ -10,6 +16,27 @@ describe('agentChatPath', () => {
})
})
describe('agentRunHref', () => {
const agentFlow = { modules: [{ id: '__wm_agent_root' }] }
it('links a chat run to the agent editor and a one-shot run to its page', () => {
expect(agentRunHref({ job_kind: 'flowpreview', script_path: 'u/a/bot.chat' }, 'ws')).toBe(
'/agents/edit/u/a/bot?workspace=ws'
)
expect(
agentRunHref({ job_kind: 'flowpreview', script_path: 'u/a/bot', raw_flow: agentFlow }, 'ws')
).toBe('/agents/get/u/a/bot?workspace=ws')
})
it('leaves any other flow preview alone', () => {
expect(
agentRunHref(
{ job_kind: 'flowpreview', script_path: 'u/a/flow', raw_flow: { modules: [{ id: 'a' }] } },
'ws'
)
).toBeUndefined()
expect(agentRunHref({ job_kind: 'flow', script_path: 'u/a/bot.chat' }, 'ws')).toBeUndefined()
})
})
describe('agentStepLabel', () => {
it("names a run's agent step by the agent, a chat turn's included", () => {
expect(agentStepLabel('f/support/agent')).toBe('f/support/agent')
@@ -17,6 +17,26 @@ export function agentChatPath(agentPath: string): string {
* scoping and the run's agent detection match on it, so it is only ever relabeled for display. */
export const AGENT_STEP_ID = '__wm_agent_root'
/**
* Where a run's path links when the run is an agent's, which is a flow preview filed under no flow:
* a test run (`.chat`) to the agent's editor, a run of the deployed agent to its page. Undefined
* for any other run.
*/
export function agentRunHref(
job: { job_kind?: string; script_path?: string; raw_flow?: { modules?: { id: string }[] } },
workspace: string | undefined
): string | undefined {
const path = job.script_path
if (job.job_kind !== 'flowpreview' || !path) return undefined
const query = workspace ? `?workspace=${workspace}` : ''
if (path.endsWith('.chat')) return `/agents/edit/${path.slice(0, -'.chat'.length)}${query}`
const modules = job.raw_flow?.modules
if (modules?.length === 1 && modules[0].id === AGENT_STEP_ID) {
return `/agents/get/${path}${query}`
}
return undefined
}
/** What a run shows for that step: the agent it ran, read off the run's path. */
export function agentStepLabel(runPath: string | undefined): string {
return runPath?.replace(/\.chat$/, '') || 'AI Agent'
@@ -99,7 +99,7 @@
{item}
value="read_run"
label="Read and run"
tooltip="Explore, and run deployed scripts and flows. The client changes nothing itself; the scripts and flows it runs can."
tooltip="Explore, and run deployed scripts, flows and agents. The client changes nothing itself; what it runs can."
/>
<ToggleButton
{item}
@@ -2,6 +2,7 @@ import type { Job } from '$lib/gen'
import { triggerDisplayNamesMap, triggerIconMap } from '$lib/components/triggers/utils'
import { formatMemory } from '$lib/utils'
import { flowPathToHref } from '$lib/scripts'
import { agentRunHref } from '$lib/components/flows/conversations/agentEditorChat'
import { Calendar, Bot } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
@@ -264,10 +265,12 @@ export const fieldConfigs: Record<JobField, FieldConfig> = {
field: 'script_path',
label: 'Path',
getValue: (job) => job.script_path || null,
getHref: (job, _workspaceId) => {
getHref: (job, workspaceId) => {
if (!job.script_path) return null
const isScript = job.job_kind === 'script'
return isScript ? `/scripts/get/${job.script_hash}` : flowPathToHref(job.script_path)
return isScript
? `/scripts/get/${job.script_hash}`
: (agentRunHref(job, workspaceId) ?? flowPathToHref(job.script_path))
}
},
@@ -28,6 +28,7 @@
type FieldConfig
} from './JobDetailFieldConfig'
import { flowPathToHref } from '$lib/scripts'
import { agentRunHref } from '$lib/components/flows/conversations/agentEditorChat'
import { slide } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -473,7 +474,7 @@
{@const isScript = job.job_kind === 'script'}
{@const viewHref = isScript
? `/${stem}/get/${job?.script_hash}`
: flowPathToHref(job?.script_path ?? '')}
: (agentRunHref(job, $operatingWorkspace) ?? flowPathToHref(job?.script_path ?? ''))}
<a
href={viewHref}
target="_blank"
@@ -22,6 +22,7 @@
barePath
} from './previewRouter'
import { useSessionPermissions } from './sessionPermissions.svelte'
import { agentWriteCount } from '$lib/components/flows/agentEditorStore.svelte'
let {
runtime,
@@ -41,7 +42,7 @@
tabId: string
/** The tab's host element, which also holds the overlays the page portals out. */
container: HTMLElement | undefined
kind: SessionTargetKind
kind: SessionTargetKind | 'agent'
path: string
/** Deployed version this tab is pinned to, from its URL's `?version=`. */
version?: string
@@ -76,7 +77,10 @@
// bumps this counter through `itemDeployed`; a deploy from the chat arrives
// instead as `refresh()`, via `previewReload`'s item list. Neither covers a deploy made
// outside the session (Compare & Deploy, the CLI, another browser tab).
const revision = $derived(runtime.deployedRevision(kind, path))
// An agent is deployed from its own editor, which counts its writes for every surface showing it.
const revision = $derived(
kind === 'agent' ? agentWriteCount(workspaceId, path) : runtime.deployedRevision(kind, path)
)
let reloadKey = $state(0)
// Seeded from the current value on purpose: before the first run this holds "no deploy
// seen yet", so a fresh mount must not count as one.
@@ -133,6 +137,10 @@
}
function toEditSide() {
if (kind === 'agent') {
runtime.previewTabs.open({ type: 'pageitem', ref: { kind: 'resource', path } })
return
}
onNavigate(
kind === 'flow'
? `/flows/edit/${path}`
@@ -148,6 +156,22 @@
function onNavigate(url: string): void {
const clean = barePath(url)
const query = new URL(url, 'http://x').searchParams
// An agent's editor is its resource tab; another agent's page is this same view.
const agentEdit = clean.match(/^\/agents\/edit\/(.+)$/)
if (agentEdit) {
runtime.previewTabs.open({
type: 'pageitem',
ref: { kind: 'resource', path: decodeURIComponent(agentEdit[1]) }
})
return
}
if (/^\/agents\/get\/.+$/.test(clean)) {
runtime.previewTabs.navigate(
{ type: 'page', href: clean, label: previewLocationLabel(clean) },
tabId
)
return
}
const run = clean.match(/^\/run\/([^/?#]+)$/)
if (run) {
const jobId = decodeURIComponent(run[1])
@@ -234,7 +258,7 @@
<div class="text-sm text-primary">Not deployed yet</div>
<div class="text-xs text-tertiary max-w-sm">
Nothing is deployed at <span class="font-mono">{path}</span>, so there is no deployed
version to view.{#if canEdit}
version to view.{#if canEdit}{' '}
Deploy it from the editor to see it here.{/if}
</div>
{#if canEdit}
@@ -243,6 +267,20 @@
</Button>
{/if}
</div>
{:else if kind === 'agent'}
<!-- Loaded when shown: the agent page carries the agent editor, the flow editor's graph. -->
{#await import('$lib/components/flows/content/AgentDetailPage.svelte') then Module}
<Module.default
{path}
workspace={workspaceId}
embedded
{active}
{onNavigate}
{pendingRun}
onLoadState={setLoadState}
onSummary={(summary) => runtime.previewTabs.setTabFriendlyLabel(tabId, summary)}
/>
{/await}
{:else if kind === 'script'}
<ScriptDetail
hash={path}
@@ -2,6 +2,7 @@
import { setContext, untrack } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { enterpriseLicense } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
import { resource } from 'runed'
import { Button } from '$lib/components/common'
import { lookupPageItem } from './pageItemLookup'
@@ -81,9 +82,7 @@
const t = triggerEditor
untrack(() => {
v?.editVariable(path)
// An agent opens on JSON: the generic form would render its configuration field by
// field and write a default into each one the value leaves out, drafting just by opening.
void r?.initEdit(path, { json: lookup.current?.resourceType === 'ai_agent' })
void r?.initEdit(path)
void t?.openEdit(path, false)
})
})
@@ -170,6 +169,21 @@
{onSaved}
onClose={closeTab}
/>
{:else if item.kind === 'resource' && lookup.current.resourceType === 'ai_agent'}
<!-- An agent has an editor of its own, which the resource editor would show as JSON. -->
{#await import('$lib/components/flows/content/AgentEditorModal.svelte')}
{@render loading()}
{:then Module}
<Module.default
layout="pane"
fixedTarget={{ path: item.path, workspace: workspaceId }}
owns={() => false}
enableAi={$copilotInfo.enabled}
onClose={closeTab}
onRenamed={(_, to) => onSaved(to)}
onSummary={(summary) => runtime.previewTabs.stampAgentTab(item, summary)}
/>
{/await}
{:else if item.kind === 'resource'}
<ResourceEditorDrawer
bind:this={resourceEditor}
@@ -16,9 +16,10 @@ section or the flat layout.
<script lang="ts">
import { untrack } from 'svelte'
import { Compass, FileText, NetworkIcon, Pen } from 'lucide-svelte'
import { Bot, Compass, FileText, NetworkIcon, Pen } from 'lucide-svelte'
import { resource } from 'runed'
import { AssetService } from '$lib/gen'
import { listWorkspaceAgents } from './workspaceAgents'
import { workspaceStore } from '$lib/stores'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import {
@@ -186,6 +187,17 @@ section or the flat layout.
// draft-only ones a plain click already opens there: a row that drops the button to say
// "you were getting this anyway" reads as one that cannot be edited.
function editAction(leaf: DrillLeaf<PreviewTarget>) {
const agentPath = agentPathOf(leaf)
if (agentPath !== undefined) {
return canEditAgents
? {
icon: Pen,
label: 'Edit',
title: `Edit ${agentPath}`,
run: () => onPick(agentEditTarget(agentPath))
}
: undefined
}
const target = leaf.data
if (target.type !== 'item' || !canEdit(target.item)) return undefined
return {
@@ -199,6 +211,13 @@ section or the flat layout.
// What the row says about the item's deploy state, in the review dock's words so
// the two never describe the same item differently.
function stateBadge(leaf: DrillLeaf<PreviewTarget>) {
const agentPath = agentPathOf(leaf)
if (agentPath !== undefined) {
const agent = shownAgents.find((a) => a.path === agentPath)
if (agent?.draftOnly) return { label: 'Draft only', color: 'indigo' as const }
if (agent?.hasDraft) return { label: 'Draft', color: 'indigo' as const }
return undefined
}
const target = leaf.data
if (target.type !== 'item') return undefined
if (target.item.draftOnly) return { label: 'Draft only', color: 'indigo' as const }
@@ -278,11 +297,51 @@ section or the flat layout.
: undefined
)
// Agents are resources rather than workspace items, so they get a branch of their own. A
// deployed one lands on its page, as picking any item does; a draft-only one has only its
// editor, so it is offered only to a user who can open that.
const agentsResource = resource(
() => effectiveWorkspace,
(ws) => (ws ? listWorkspaceAgents(ws) : Promise.resolve([]))
)
const canEditAgents = $derived(permissions.canOpenEditor('agent'))
function agentEditTarget(path: string): PreviewTarget {
return { type: 'pageitem', ref: { kind: 'resource', path } }
}
const shownAgents = $derived(
(agentsResource.current ?? []).filter((a) => canEditAgents || !a.draftOnly)
)
const agentsBranch = $derived<DrillBranch<PreviewTarget> | undefined>(
shownAgents.length > 0
? {
type: 'branch',
key: 'agents',
label: 'AI agents',
icon: Bot,
searchGroup: true,
children: shownAgents.map((a) => ({
type: 'leaf' as const,
key: `agent:${a.path}`,
label: a.summary || a.path,
secondary: a.summary ? a.path : undefined,
icon: Bot,
data: a.draftOnly
? agentEditTarget(a.path)
: { type: 'page', href: `/agents/get/${a.path}`, label: a.summary || a.path }
}))
}
: undefined
)
function agentPathOf(leaf: DrillLeaf<PreviewTarget>): string | undefined {
return leaf.key.startsWith('agent:') ? leaf.key.slice('agent:'.length) : undefined
}
const tree = $derived<DrillNode<PreviewTarget>[]>([
// An operator's workspace may enable none of them.
...(pagesBranch.children.length > 0 ? [pagesBranch] : []),
...(artifactsBranch ? [artifactsBranch] : []),
...(pipelinesBranch ? [pipelinesBranch] : []),
...(agentsBranch ? [agentsBranch] : []),
...tagItems(
buildWorkspaceTree({
loaded: loader.loaded,
@@ -31,6 +31,8 @@
import { previewTargetForDeployKind } from './sessionPreviewTabs.svelte'
import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
import type { PreviewTarget } from './previewRouter'
import { ResourceService } from '$lib/gen'
import { resource } from 'runed'
import JobsSegment from '$lib/components/copilot/chat/JobsSegment.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ArtifactsSegment from '$lib/components/copilot/chat/artifacts/ArtifactsSegment.svelte'
@@ -163,6 +165,31 @@
: []
)
const dockCounts = $derived(badgeCounts(dockItems))
// An agent is a resource the draft list cannot tell apart, but it has an icon of its own.
// Keyed on the joined paths so a re-derived list doesn't re-fetch.
const resourcePaths = $derived(
dockItems
.filter((i) => i.deployKind === 'resource')
.map((i) => i.path)
.join('\n')
)
const agentPaths = resource(
() => [committedId, resourcePaths] as const,
async ([workspace, joined]) => {
if (!workspace || !joined) return new Set<string>()
const types = await Promise.all(
joined.split('\n').map((path) =>
ResourceService.getResource({ workspace, path, getDraft: true }).then(
(r) => (r.resource_type === 'ai_agent' ? path : undefined),
() => undefined
)
)
)
return new Set(types.filter((p) => p !== undefined))
}
)
const isAgentRow = (item: DeployItem) =>
item.deployKind === 'resource' && (agentPaths.current?.has(item.path) ?? false)
// Deletion-only fork chat: the mask has entries but every one failed the
// (resolved) existence check — the chat's edits were deletions, which have no
@@ -210,7 +237,11 @@
function previewTargetFor(item: DeployItem): PreviewTarget | undefined {
// `done` means this session already deployed it, so there is a deployed
// version to show even though the row was built from a draft-only item.
return previewTargetForDeployKind(item.deployKind, item.path, item.draftOnly && !item.done)
const draftOnly = item.draftOnly && !item.done
if (isAgentRow(item) && !draftOnly) {
return { type: 'page', href: `/agents/get/${item.path}`, label: rowLabel(item) }
}
return previewTargetForDeployKind(item.deployKind, item.path, draftOnly)
}
// The row's primary action is the preview; kinds the panel can't host fall back
@@ -331,7 +362,11 @@
maxHeightClass="max-h-[min(9rem,50vh)]"
>
{#snippet row(item)}
<RowIcon kind={item.deployKind} path={item.path} size={14} />
<RowIcon
kind={isAgentRow(item) ? 'agent' : item.deployKind}
path={item.path}
size={14}
/>
<span class="min-w-0 flex-1 truncate font-mono font-normal text-primary">
{rowLabel(item)}
</span>
@@ -389,6 +424,7 @@
{parentWorkspaceId}
chatId={session.chatId}
keys={mask}
agentPaths={agentPaths.current}
onDataChanged={refreshDock}
onItemDeployed={(item) =>
void runtime?.manager.renameModifiedItem(item.draftKind, item.path, item.displayPath)}
@@ -21,7 +21,8 @@
onItemDeployed,
onItemDiscarded,
deployPipeline,
readOnly = false
readOnly = false,
agentPaths
}: {
workspaceId: string
parentWorkspaceId?: string
@@ -42,6 +43,8 @@
deployPipeline?: (folder: string) => Promise<DeployResult>
/** Forwarded to the deploy model: the session is in Use. */
readOnly?: boolean
/** Resource rows that are agents, which get the agent's icon and editor. */
agentPaths?: Set<string>
} = $props()
const isFork = $derived(!!parentWorkspaceId)
@@ -70,9 +73,13 @@
if (item.deployKind === 'script') return `/scripts/edit/${path}?workspace=${ws}`
if (item.deployKind === 'app') return `/apps/edit/${path}?workspace=${ws}`
if (item.deployKind === 'raw_app') return `/apps_raw/edit/${path}?workspace=${ws}`
if (isAgent(item)) return `/agents/edit/${path}?workspace=${ws}`
return undefined
}
const isAgent = (item: DeployItem) =>
item.deployKind === 'resource' && (agentPaths?.has(item.path) ?? false)
const title = 'Edited during session'
// Compare page = the batch/PR surface. The footer hands off THIS session's
@@ -92,6 +99,7 @@
{model}
{title}
{editUrlFor}
iconKindFor={(item) => (isAgent(item) ? 'agent' : undefined)}
{compareSessionHref}
workspaceLabel={ws?.name ?? workspaceId}
>
@@ -15,7 +15,9 @@
User
} from 'lucide-svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import WorkspaceItemRow, {
type WorkspaceItemRowKind
} from '$lib/components/WorkspaceItemRow.svelte'
import WorkspaceItemDiffViewer from '$lib/components/WorkspaceItemDiffViewer.svelte'
import {
rawAppDiffToItems,
@@ -50,6 +52,7 @@
model,
title,
editUrlFor = undefined,
iconKindFor = undefined,
titleExtra,
compareSessionHref,
workspaceLabel,
@@ -59,6 +62,8 @@
title: string
/** Editor URL for a row (opens in the workspace the item lives in). */
editUrlFor?: (item: DeployItem) => string | undefined
/** Icon kind for a row whose deploy kind doesn't name it, e.g. an agent's resource. */
iconKindFor?: (item: DeployItem) => WorkspaceItemRowKind | undefined
titleExtra?: Snippet
/** Display name of the workspace deploys land in — the deployed tick's
* hover reads "Deployed in <label>". */
@@ -783,7 +788,7 @@
</WorkspaceItemRow>
{:else}
<WorkspaceItemRow
kind={d.deployKind as any}
kind={(iconKindFor?.(d) ?? d.deployKind) as any}
iconPath={d.path}
baseClass="py-2 min-w-0 pr-1 pl-1 rounded-md"
singleLine
@@ -1027,7 +1032,11 @@
bind:clientHeight={cardHeaderH}
class="sticky top-0 z-30 bg-surface-tertiary rounded-t-md flex items-center gap-2 px-3 py-2 border-b border-transparent"
>
<RowIcon kind={d.deployKind as any} path={d.path} size={14} />
<RowIcon
kind={(iconKindFor?.(d) ?? d.deployKind) as any}
path={d.path}
size={14}
/>
<!-- flex+items-center so the path (a block div, or ExternalEditLink's
inline-flex <a>) is centered by its box against the icon — an inline
<a> would otherwise sit ~2px low on the line-box baseline. -->
@@ -127,11 +127,19 @@ export function pageDrawerSessionSource(
// Text that does not parse never reaches the draft, so routing is refused instead.
beforeOpen:
itemKind && workspaceId
? async () => {
flushAllPendingEditorChanges()
await tick()
await flushOrRefuse({ workspace: workspaceId, itemKind, path: itemPath })
}
? () => persistDraftForSession(workspaceId, itemKind, itemPath)
: undefined
}
}
/** Save the draft of the item on screen before a session opens it, or throw saying why it cannot
* be: the preview reads the draft back through the server, from a document of its own. */
export async function persistDraftForSession(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): Promise<void> {
flushAllPendingEditorChanges()
await tick()
await flushOrRefuse({ workspace, itemKind, path })
}
@@ -166,6 +166,18 @@ export function barePath(path: string): string {
)
}
/** The agent a `/agents/get/<path>` location shows; undefined for any other location, a malformed
* encoding included, which `decodeURIComponent` would otherwise throw on mid-render. */
export function agentPagePath(loc: string): string | undefined {
const m = barePath(loc).match(/^\/agents\/get\/(.+)$/)
if (!m) return undefined
try {
return decodeURIComponent(m[1])
} catch {
return undefined
}
}
/** Which side of an item a route addresses: `/edit/` builds it, `/get/` shows the
* deployed version. The two are one tab in a session, switched by re-pointing its URL. */
export type PreviewItemMode = 'edit' | 'view'
@@ -98,6 +98,27 @@ describe('tabsToReload', () => {
])
})
it("reloads the resources list on an agent write but not the agent's editor, which a discard does", () => {
const url = pageItemUrl({ kind: 'resource', path: 'u/me/a' })
const agentTab: SessionPreviewTab = { id: 'a', url, loc: url }
const reloaded = (name: string, args: any) => {
const { pages, items, live = [] } = toolReloadEffect(name, args)
return tabsToReload(
[resourceTab, agentTab],
new Set(pages),
new Set(items.map(pageItemUrl)),
new Set(live.map(pageItemUrl))
)
}
expect(reloaded('write_resource', { resource_type: 'ai_agent', path: 'u/me/a' })).toEqual([
resourceTab
])
expect(reloaded('discard_local_draft', { type: 'resource', path: 'u/me/a' })).toEqual([
resourceTab,
agentTab
])
})
it('is empty when no pages were touched', () => {
expect(tabsToReload(tabs, new Set())).toEqual([])
})
@@ -127,6 +148,19 @@ describe('viewerTabsToReload', () => {
])
})
it("refreshes an agent's deployed page when the chat deletes or deploys it", () => {
const agentTab: SessionPreviewTab = {
id: 'a',
url: '/agents/get/u/me/bot',
loc: '/agents/get/u/me/bot'
}
const { deployed } = toolReloadEffect('delete_workspace_item', {
type: 'resource',
path: 'u/me/bot'
})
expect(viewerTabsToReload([agentTab, viewTab], new Set(deployed))).toEqual([agentTab])
})
// The editor holds what the user is writing; refetching the deployed version over it
// would discard that, and it self-syncs from the store the chat already mutated.
it('never returns the edit side, even for the item that was deployed', () => {
@@ -6,6 +6,7 @@ import {
parsePageItemRoute,
parsePreviewItemRoute,
barePath,
agentPagePath,
TRIGGER_PAGES,
type PageItemRef,
type TriggerKind
@@ -33,7 +34,15 @@ import {
// the chat, so a tool that changes what is deployed at a path has to name it here
// or that tab keeps showing the previous deployment. Only tools that change the
// deployed item belong in `deployed`; a draft write does not.
export type ToolReloadEffect = { pages: string[]; items: PageItemRef[]; deployed: string[] }
//
// `live` names items whose open editor follows this tool's write on its own, so naming them
// reloads their list page but leaves their tab alone.
export type ToolReloadEffect = {
pages: string[]
items: PageItemRef[]
deployed: string[]
live?: PageItemRef[]
}
const NO_RELOAD: ToolReloadEffect = { pages: [], items: [], deployed: [] }
export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
@@ -43,8 +52,13 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
case 'write_trigger':
// Its path sits in the trigger's own config, not beside `kind`.
return withItem(triggerPages(args?.kind), itemRef('trigger', args?.config, args?.kind))
case 'write_resource':
return withItem(['/resources'], itemRef('resource', args))
case 'write_resource': {
const item = itemRef('resource', args)
const effect = withItem(['/resources'], item)
// An agent's editor follows the chat's writes through the draft cell they share, so
// reloading its tab would only throw away where the reader was in it, a test chat included.
return args?.resource_type === 'ai_agent' && item ? { ...effect, live: [item] } : effect
}
case 'write_variable':
return withItem(['/variables'], itemRef('variable', args))
case 'create_folder':
@@ -77,7 +91,8 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
// show the deployed side of. Other types (schedule, resource, …) have list pages instead.
function deployedItems(args: any): string[] {
const type = args?.type
if (type !== 'script' && type !== 'flow' && type !== 'app') return []
// A resource has no deployed page, except an agent's, which `viewerTabsToReload` matches.
if (type !== 'script' && type !== 'flow' && type !== 'app' && type !== 'resource') return []
return typeof args?.path === 'string' && args.path ? [args.path] : []
}
@@ -125,10 +140,12 @@ function triggerPages(kind: unknown): string[] {
export function tabsToReload(
tabs: SessionPreviewTab[],
pages: ReadonlySet<string>,
items: ReadonlySet<string> = new Set()
items: ReadonlySet<string> = new Set(),
live: ReadonlySet<string> = new Set()
): SessionPreviewTab[] {
if (pages.size === 0) return []
return tabs.filter((t) => {
if (live.has(t.url)) return false
const pageItem = parsePageItemRoute(t.url)
if (!pageItem) return pages.has(barePath(whereIs(t)))
const listPath = pageItemListPath(pageItem)
@@ -147,7 +164,10 @@ export function viewerTabsToReload(
): SessionPreviewTab[] {
if (items.size === 0) return []
return tabs.filter((t) => {
const route = parsePreviewItemRoute(whereIs(t))
const loc = whereIs(t)
const agent = agentPagePath(loc)
if (agent !== undefined) return items.has(agent)
const route = parsePreviewItemRoute(loc)
return route?.mode === 'view' && items.has(route.itemPath)
})
}
@@ -19,6 +19,7 @@ import {
RUNS_PATH,
SCHEDULES_PATH,
barePath,
agentPagePath,
VARIABLES_PATH,
WORKSPACE_SETTINGS_PATH,
triggerLabelForPath,
@@ -496,6 +497,14 @@ export function previewLocationLabel(url: string): string {
if (historical) return `${historical.path.split('/').pop()} @ ${historical.hash.slice(0, 8)}`
const parsed = parsePreviewItemRoute(url)
if (parsed) return parsed.itemPath.split('/').pop() ?? parsed.itemPath
const agent = barePath(url).match(/^\/agents\/(?:edit|get)\/(.+)$/)
if (agent) {
let path = agent[1]
try {
path = decodeURIComponent(path)
} catch {}
return path.split('/').pop() || path
}
return barePath(url)
}
@@ -628,7 +637,7 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
// else (static pages, regular drag-and-drop apps, any other route) stays an iframe.
export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
| { kind: 'viewer'; viewerKind: SessionTargetKind; path: string }
| { kind: 'viewer'; viewerKind: SessionTargetKind | 'agent'; path: string }
| { kind: 'artifact'; id: string; version?: number }
| { kind: 'runform'; toolCallId: string }
| { kind: 'pageitem'; ref: PageItemRef }
@@ -667,6 +676,9 @@ export function resolvePreviewTab(url: string): PreviewSlot {
if (pipelineFolder) {
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
}
// An agent's deployed page; its editor is the resource tab, which hosts it in process.
const agent = agentPagePath(url)
if (agent !== undefined) return { kind: 'viewer', viewerKind: 'agent', path: agent }
const item = parsePreviewItemRoute(url)
if (!item || parseHistoricalScriptEdit(url)) return { kind: 'iframe' }
const itemKind: SessionTargetKind | undefined =
@@ -136,6 +136,7 @@ function retargetTab(tab: SessionPreviewTab, url: string): void {
tab.friendlyLabel = undefined
tab.friendlyPath = undefined
tab.editorNamed = undefined
tab.agentEditor = undefined
}
tab.url = url
tab.loc = url
@@ -648,6 +649,27 @@ export class SessionPreviewTabs {
this.#flush()
}
/** Name a tab after what the page in it shows, for a page that is not one of the live editors
* (an agent's deployed page); undefined falls back to the location's label. */
setTabFriendlyLabel(tabId: string, label: string | undefined): void {
const tab = this.#tabs.find((t) => t.id === tabId)
if (!tab || (tab.editorNamed && tab.friendlyLabel === label)) return
tab.friendlyLabel = label
tab.editorNamed = true
this.#flush()
}
/** Mark a resource tab as an agent's editor, named after its summary as `setEditorFriendlyLabel`
* names the live editors' tabs; an undefined summary falls back to the path. */
stampAgentTab(ref: PageItemRef, summary: string | undefined): void {
const tab = this.#tabs.find((t) => t.url === pageItemUrl(ref))
if (!tab || (tab.agentEditor && tab.editorNamed && tab.friendlyLabel === summary)) return
tab.agentEditor = true
tab.friendlyLabel = summary
tab.editorNamed = true
this.#flush()
}
/** Follow an item its editor deployed under a new path, in place: the editor is keyed
* on the path, and nothing is left to edit at the old one. */
retargetEditor(from: SessionTarget, to: SessionTarget): void {
@@ -84,7 +84,8 @@ import {
resolvePreviewTab,
pendingRunIdOf,
slotShowsPage,
type PreviewSlot
type PreviewSlot,
type PreviewTarget
} from './previewRouter'
import { normalizePipelineFolder } from '$lib/utils/pipelineFolder'
import { logFeatureUsage } from '$lib/utils/featureUsage'
@@ -484,10 +485,19 @@ function createRuntime(session: Session): SessionRuntime {
// to resolve "this page" and "it" against this block, and a collapsed panel would
// point those at a page the user cannot see.
const tab = owner?.displayedTab
if (!tab) return undefined
if (!tab || tab.agentEditor) return undefined
if (!slotShowsPage(resolvePreviewTab(tab.url).kind)) return undefined
return previewLocationContext(whereIs(tab))
}
// An agent's editor is a resource tab rather than one of the live editors the draft registry
// reports, so the tab model says when one is on screen.
manager.activeEditorResolver = () => {
const tab = getRuntime(session.id)?.previewTabs?.displayedTab
if (!tab?.agentEditor) return undefined
const slot = resolvePreviewTab(tab.url)
if (slot.kind !== 'pageitem') return undefined
return { type: 'agent', path: slot.ref.path, storagePath: slot.ref.path, isLiveDraft: true }
}
// Pre-flight: materialise the (still-transient) session, then commit
// the workspace (creating a staged fork if needed) before any send.
// AIChatManager awaits this so the first message hits a persisted
@@ -652,16 +662,28 @@ function createRuntime(session: Session): SessionRuntime {
carries,
onlyIfAlreadyOpen
}) => {
// Through the shared adapter: a session target spells a code-based app `raw_app` and a
// workspace item spells it `app` with a flag, and one place should know that.
const target = previewTargetForSessionTarget(kind, path)
if (target?.type !== 'item') return false
const viewTarget = {
...target,
item: { ...target.item, summary },
mode: 'view' as const,
pendingRunId: carries ? toolCallId : undefined
}
// An agent's deployed page is a page route, which the panel hosts in process; the call
// rides in its query, as an item viewer's does.
const viewTarget: PreviewTarget | undefined =
kind === 'agent'
? {
type: 'page',
href: `/agents/get/${path}${carries ? `?pending_run=${encodeURIComponent(toolCallId)}` : ''}`,
label: summary
}
: (() => {
// Through the shared adapter: a session target spells a code-based app `raw_app`
// and a workspace item spells it `app` with a flag, and one place should know that.
const target = previewTargetForSessionTarget(kind, path)
if (target?.type !== 'item') return undefined
return {
...target,
item: { ...target.item, summary },
mode: 'view' as const,
pendingRunId: carries ? toolCallId : undefined
}
})()
if (!viewTarget) return false
// Re-pointed rather than focused: the url is what hands the tab the call, and
// rebuilding it also drops whatever version the reader had pinned.
const open = previewTabs.tabs.find((t) => {
@@ -1526,6 +1548,19 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path, mode }) =
if (!session) {
return 'Error: no active session to open the preview in.'
}
// An agent's editor is its resource tab, and its deployed page the agent's own.
if (kind === 'agent') {
const view = mode === 'view'
const result = getOrCreateRuntime(session).previewTabs.open(
view
? { type: 'page', href: `/agents/get/${path}`, label: path }
: { type: 'pageitem', ref: { kind: 'resource', path } }
)
const side = view ? 'deployed page' : 'editor'
return result.status === 'focused'
? `A preview tab is already showing the ${side} of agent "${path}" — focused it.`
: `Opened the ${side} of agent "${path}" in a new tab in the side panel.`
}
const target = previewTargetForSessionTarget(
kind,
mode === 'view' || kind === 'pipeline' ? path : draftStoragePath(session, kind, path)
@@ -199,6 +199,9 @@ export type SessionPreviewTab = {
// from the workspace listing; after, the editor is the only source, else
// clearing a summary would fall back to the listing's stale copy of it.
editorNamed?: boolean
// Stamped by an agent's editor in a resource tab, which is what makes that tab the chat's
// ACTIVE EDITOR rather than a page it previews.
agentEditor?: boolean
}
// Sessions live in one per-user IndexedDB, one record per session in the
@@ -0,0 +1,45 @@
import { DraftService, ResourceService } from '$lib/gen'
export type WorkspaceAgent = {
path: string
summary: string
draftOnly: boolean
hasDraft: boolean
}
/** The workspace's agents as a session shows them: the deployed ones, then those that exist only
* as a draft. The draft listing does not say which resources are agents, so those are asked. */
export async function listWorkspaceAgents(workspace: string): Promise<WorkspaceAgent[]> {
const [deployed, drafts] = await Promise.all([
ResourceService.listResource({ workspace, resourceType: 'ai_agent', perPage: 1000 }),
// Without drafts the deployed agents still list, unmarked.
DraftService.listDrafts({ workspace }).catch((error) => {
console.error('Failed to list drafts for the workspace agents', error)
return []
})
])
const resourceDrafts = drafts.filter((d) => d.kind === 'resource')
const drafted = new Set(resourceDrafts.map((d) => d.path))
const draftOnly = await Promise.all(
resourceDrafts
.filter((d) => d.draft_only)
.map((d) =>
ResourceService.getResource({ workspace, path: d.path, getDraft: true }).then(
(r) =>
r.resource_type === 'ai_agent'
? { path: d.path, summary: r.description ?? '', draftOnly: true, hasDraft: true }
: undefined,
() => undefined
)
)
)
return [
...deployed.map((r) => ({
path: r.path,
summary: r.description ?? '',
draftOnly: false,
hasDraft: drafted.has(r.path)
})),
...draftOnly.filter((a) => a !== undefined)
]
}
+10 -1
View File
@@ -58,6 +58,7 @@ import { saveSqsTriggerFromCfg } from '$lib/components/triggers/sqs/utils'
import { saveGcpTriggerFromCfg } from '$lib/components/triggers/gcp/utils'
import { saveAzureTriggerFromCfg } from '$lib/components/triggers/azure/utils'
import { saveEmailTriggerFromCfg } from '$lib/components/triggers/email/utils'
import { markAgentWritten } from '$lib/components/flows/agentEditorStore.svelte'
export type DraftKind = UserDraftItemKind
@@ -368,9 +369,14 @@ export async function getDraftDiffValues(
throw new Error(`Draft diff not supported for kind ${kind}`)
}
const { deployed, draft, hasDraft, noDeployed } = splitOverlay(await getter(workspace, path))
// The agent editor's draft of a deployed agent omits its type, which deploying keeps.
const draftWithType =
kind === 'resource' && draft && !draft.resource_type && deployed?.resource_type
? { ...draft, resource_type: deployed.resource_type }
: draft
return {
deployed: draftOnly ? {} : canonicalizeDraftDiffValue(kind, deployed, false),
draft: canonicalizeDraftDiffValue(kind, draft, true),
draft: canonicalizeDraftDiffValue(kind, draftWithType, true),
hasDraft,
noDeployed
}
@@ -693,6 +699,9 @@ export async function deployDraft(
}
})
}
// What shows a deployed agent reads it again on this, wherever the deploy came from.
if (!noop && (d.resource_type ?? deployed?.resource_type) === 'ai_agent')
markAgentWritten(workspace, d.path ?? path)
} else if (kind === 'trigger_schedule') {
const { draft: d } = splitOverlay(await OVERLAY_GETTERS.trigger_schedule!(workspace, path))
// The schedule editor's draft IS the cfg shape `saveScheduleFromCfg`
@@ -1,335 +1,10 @@
<script lang="ts">
import { page } from '$app/state'
import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { resource } from 'runed'
import { goto } from '$lib/navigation'
import { copilotInfo } from '$lib/aiStore'
import AgentEvalsModal from '$lib/components/flows/content/AgentEvalsModal.svelte'
import AgentUsePanel from '$lib/components/flows/content/AgentUsePanel.svelte'
import { SlackAgentsService } from '$lib/gen'
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import { Alert, Tab, Tabs } from '$lib/components/common'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
import AgentHistory from '$lib/components/flows/AgentHistory.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { recentItems } from '$lib/components/home/recentItems.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { useEditRights } from '$lib/operatorWriteRights'
import { sendUserToast } from '$lib/toast'
/**
* The deployed agent, as a flow's or a script's page shows theirs: a way to run it, a chat when
* its memory keeps the conversation and the inputs form otherwise, beside what it is.
*/
let path = $derived(page.params.path ?? '')
let ws = $derived($workspaceStore)
/** Bumped to read the agent again after it was renamed in place. */
let reloaded = $state(0)
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let agent = $derived(host?.draftHandle())
let testPane = $derived(host?.testPaneHandle())
let config = $derived(agent?.state?.args)
let chatAvailable = $derived(keepsManagedMemory(config?.memory))
const editRights = useEditRights()
let canAuthor = $derived(editRights.roleCanAuthor('agent'))
let canCode = $derived(editRights.capabilities.code)
let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && canAuthor)
// Recorded once the agent is in: a path with nothing deployed never loads one.
let loaded = $derived(config != undefined)
$effect(() => {
if (ws && loaded) {
recentItems.record(ws, {
kind: 'agent',
path,
summary: agent?.state?.description,
via: 'view'
})
}
})
let configModal: AgentConfigModal | undefined = $state(undefined)
let shareModal: ShareModal | undefined = $state(undefined)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
let history: AgentHistory | undefined = $state(undefined)
let deleteOpen = $state(false)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
let clientWidth = $state(window.innerWidth)
let pickedMobileTab = $state('agent')
// The page is reused across agents, and an agent without managed memory has no Slack tab.
let mobileTab = $derived(pickedMobileTab === 'slack' && !chatAvailable ? 'api' : pickedMobileTab)
let wide = $derived(clientWidth >= 1024)
const slack = resource(
() => ({ workspace: ws, path }),
async ({ workspace, path }) =>
workspace ? await SlackAgentsService.getAgentSlackChannels({ workspace, path }) : undefined
)
let runForm: RunForm | undefined = $state(undefined)
let isValid = $state(true)
// Cmd/Ctrl+Enter runs the form, as on a script's or a flow's page. The form stays mounted, hidden,
// while the chat shows, and the chat's composer owns the key then.
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return
if (!runForm || testPane?.mode === 'chat' || (!wide && mobileTab !== 'agent')) return
if (isValid) {
event.preventDefault()
runForm.run()
} else {
sendUserToast('Please fix errors before running', true)
}
}
async function remove() {
if (ws && (await deleteAgent(ws, path))) await goto('/?kind=agent')
}
// The workspace's, so a path change never waits on it and a menu never outlives its path.
const deployUiSettings = resource(
() => ws,
() => getDeployUiSettings()
)
// A role that cannot build agents gets no menu, as on a flow's page.
let menuItems = $derived(
config != undefined && canAuthor
? agentMenuItems({
path,
canWrite: canEdit,
noCode: !canCode,
wsSpecific: agent?.state?.wsSpecific,
deployUiSettings: deployUiSettings.current,
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
onHistory: () => history?.open(),
onDelete: () => (deleteOpen = true)
}).map((item) => ({
label: item.displayName,
Icon: item.icon,
onclick: (e: MouseEvent) => item.action?.(e),
disabled: item.disabled,
color: item.type === 'delete' ? ('red' as const) : undefined
}))
: []
)
import AgentDetailPage from '$lib/components/flows/content/AgentDetailPage.svelte'
import { workspaceStore } from '$lib/stores'
</script>
<ShareModal bind:this={shareModal} />
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<AgentHistory bind:this={history} {path} canRestore={canEdit} onRestored={() => reloaded++} />
<ConfirmationModal
open={deleteOpen}
title="Delete agent"
confirmationText="Delete"
on:canceled={() => (deleteOpen = false)}
on:confirmed={() => {
deleteOpen = false
remove()
}}
>
<span>Every flow that links {path} will fail at its agent step once it is deleted.</span>
</ConfirmationModal>
<svelte:window onkeydown={onKeyDown} />
<main class="h-full w-full flex flex-col">
<!-- `labels`: the pen saves every field it shows in one write, so the labels it shows have to be
the ones the resource carries. Handed none, it would offer an empty list, and the first
label added there would replace the agent's own. -->
<DetailPageHeader
ownsPageHeader
itemKind="agent"
summary={config ? agent?.state?.description : undefined}
labels={agent?.state?.labels}
{path}
{menuItems}
onSaved={canEdit
? async (newPath) => {
// A rename moves the page; a summary saved in place has to be read again, since
// what is shown here came from the load this page keys on.
if (newPath !== path) await goto(`/agents/get/${newPath}`)
else reloaded++
}
: undefined}
mainButtons={[
// Evaluating an agent builds datasets and runs against it: authoring, as editing is, and
// eval datasets are refused to a role without `code` whatever its flows right.
...(canEdit && canCode
? [
{
label: 'Evals',
buttonProps: {
variant: 'default',
unifiedSize: 'md',
startIcon: FlaskConical,
title: 'Run this agent against a dataset of cases',
onClick: () => evalsModal?.openModal()
}
}
]
: []),
...(canEdit
? [
{
label: 'Edit',
buttonProps: {
variant: 'accent',
unifiedSize: 'md',
startIcon: Pen,
href: `/agents/edit/${path}`
}
}
]
: [])
]}
>
<OnBehalfOfBadge
onBehalfOf={config?.run_on_behalf_of ? agent?.onBehalfOf : undefined}
onBehalfOfEmail={undefined}
kind="agent"
/>
{#snippet leading_actions()}
<!-- Only an agent that keeps the conversation can chat: without managed memory every
message would be answered alone, so it is run from its inputs with nothing to switch. -->
{#if chatAvailable && testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="md"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="md"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{/snippet}
</DetailPageHeader>
<!-- Below `lg` the split would squeeze the chat, so the panel's sections become tabs of the
page, as on a script's or a flow's page. They cover the agent rather than replacing it:
unmounting the host destroys the chat and any turn in flight, so the agent pane keeps one
parent at every width. -->
{#if !wide && ws && config}
<Tabs
bind:selected={() => mobileTab, (t) => (pickedMobileTab = t)}
wrapperClass="flex-none no-scrollbar"
>
<Tab value="agent" label={testPane?.mode === 'chat' ? 'Chat' : 'Run form'} />
<Tab value="api" label="API" />
{#if chatAvailable}
<Tab value="slack" label="Slack" />
{/if}
</Tabs>
{/if}
<div class="relative flex-1 min-h-0" bind:clientWidth>
<Splitpanes>
<Pane size={wide ? 65 : 100} minSize={50}>
<div class="h-full" inert={!wide && mobileTab !== 'agent'}>
{@render agentPane()}
</div>
</Pane>
{#if wide && ws && config}
<Pane size={35} minSize={15}>
{@render usePane(ws)}
</Pane>
{/if}
</Splitpanes>
{#if !wide && ws && config}
<div class={['absolute inset-0 z-10 bg-surface', mobileTab === 'agent' && 'hidden']}>
{@render usePane(ws, mobileTab === 'slack' ? 'slack' : 'api')}
</div>
{/if}
</div>
</main>
{#snippet agentPane()}
{#key `${ws}:${path}:${reloaded}`}
<AgentEditorHost
bind:this={host}
{path}
workspace={ws}
enableAi={$copilotInfo.enabled}
view
onOpenConfig={() => configModal?.open()}
>
{#snippet viewForm({ schema, run, loading, actions })}
{#if config && !chatAvailable}
<Alert type="info" size="xs" title="Managed memory is off" class="mb-4">
Each run is answered on its own inputs, so this agent has no chat. That suits reusing it
as a step in a flow.{canEdit
? ' Turn on managed memory in its configuration to chat with it.'
: ''}
</Alert>
{/if}
<RunForm
runnable={{ schema, path }}
runAction={run}
schedulable={false}
detailed={false}
autofocus
{loading}
{actions}
bind:isValid
bind:this={runForm}
/>
{/snippet}
</AgentEditorHost>
{/key}
{/snippet}
{#snippet usePane(ws: string, section?: 'api' | 'slack')}
<AgentUsePanel
agentPath={path}
workspace={ws}
isAdmin={!!($userStore?.is_admin || $userStore?.is_super_admin)}
chat={chatAvailable}
slackLoading={slack.loading && !slack.current}
slackTeamName={slack.current?.slack_team_name}
botName={slack.current?.bot_name}
missingScopes={slack.current?.missing_scopes ?? []}
signingSecretSet={slack.current?.signing_secret_set ?? true}
runsAs={slack.current?.runs_as}
anyone={slack.current?.anyone ?? false}
canShare={slack.current?.can_share ?? false}
channels={slack.current?.channels ?? []}
{section}
/>
{/snippet}
{#if ws}
<AgentEvalsModal bind:this={evalsModal} agentPath={path} workspace={ws} />
{/if}
{#if config}
<AgentConfigModal bind:this={configModal} {config} toolSchema={(id) => host?.toolSchema(id)} />
{/if}
<!-- Keyed: the route is reused across agents, and the page holds the form's args. -->
{#key `${$workspaceStore}:${page.params.path}`}
<AgentDetailPage path={page.params.path ?? ''} workspace={$workspaceStore} />
{/key}
@@ -94,7 +94,12 @@
tabsToReload,
viewerTabsToReload
} from '$lib/components/sessions/previewReload'
import { isPreviewListPath, barePath } from '$lib/components/sessions/previewPaths'
import {
isPreviewListPath,
barePath,
agentPagePath
} from '$lib/components/sessions/previewPaths'
import { listWorkspaceAgents } from '$lib/components/sessions/workspaceAgents'
import {
leafKeyFor,
loadKind,
@@ -436,8 +441,12 @@
* otherwise be two tabs with the same name. Only the editor is marked: the deployed
* page is the item itself, and a page, artifact or pipeline has no second side to be
* told apart from. */
function sideSuffix(url: string): string {
const slot = resolvePreviewTab(url)
function sideSuffix(tab: SessionPreviewTab, workspace: string): string {
// An agent's editor is a resource tab, beside the agent's own deployed page.
if (tab.agentEditor) return ' (edit)'
if (parsePageItemRoute(tab.loc) && listedAgentSummary(tab, workspace) !== undefined)
return ' (edit)'
const slot = resolvePreviewTab(tab.url)
return slot.kind === 'editor' && slot.editorKind !== 'pipeline' ? ' (edit)' : ''
}
let newTabOpen = $state(false)
@@ -658,7 +667,16 @@
// and the item paths whose deployed version changed. By workspace because a path names an
// item only within one: a write in one fork must not remount the same path's editor in a
// session on another.
let pending = new Map<string, { pages: Set<string>; items: Set<string>; deployed: Set<string> }>()
// `live` holds the items a write left to their editor; `forced` the ones another tool in the
// same round needs reloaded all the same (a discard after a write), which wins.
type ReloadScope = {
pages: Set<string>
items: Set<string>
deployed: Set<string>
live: Set<string>
forced: Set<string>
}
let pending = new Map<string, ReloadScope>()
// Reload the mounted tabs a chat round changed in each warm session acting on that
// workspace (a hidden preview would otherwise show pre-mutation content on return).
@@ -670,7 +688,12 @@
const owner = getRuntime(s.id)?.previewTabs
if (!scope || !owner) continue
const affected = [
...tabsToReload(owner.tabs, scope.pages, scope.items),
...tabsToReload(
owner.tabs,
scope.pages,
scope.items,
new Set([...scope.live].filter((u) => !scope.forced.has(u)))
),
...viewerTabsToReload(owner.tabs, scope.deployed)
]
for (const tab of affected) {
@@ -682,16 +705,28 @@
$effect(() => {
// Debounced so a burst of writes (the AI editing several files) reloads once.
setToolCompletionListener((name, args, workspace) => {
const { pages, items, deployed } = toolReloadEffect(name, args)
const { pages, items, deployed, live = [] } = toolReloadEffect(name, args)
if (pages.length === 0 && deployed.length === 0) return
let scope = pending.get(workspace)
if (!scope)
pending.set(
workspace,
(scope = { pages: new Set(), items: new Set(), deployed: new Set() })
(scope = {
pages: new Set(),
items: new Set(),
deployed: new Set(),
live: new Set(),
forced: new Set()
})
)
const liveUrls = new Set(live.map(pageItemUrl))
for (const p of pages) scope.pages.add(p)
for (const item of items) scope.items.add(pageItemUrl(item))
for (const item of items) {
const url = pageItemUrl(item)
scope.items.add(url)
if (liveUrls.has(url)) scope.live.add(url)
else scope.forced.add(url)
}
for (const d of deployed) scope.deployed.add(d)
clearTimeout(reloadHandle)
reloadHandle = setTimeout(flushReload, 500)
@@ -721,6 +756,7 @@
$effect(() => {
return registerToolDisplayActionHandler('open_created_resource', (action) => {
if (action.type !== 'open_created_resource') return
// An agent's resource tab renders the agent's own editor.
const ref: PageItemRef | undefined =
action.resource === 'trigger'
? action.triggerKind && {
@@ -728,7 +764,7 @@
triggerKind: action.triggerKind,
path: action.path
}
: { kind: action.resource, path: action.path }
: { kind: action.resource === 'agent' ? 'resource' : action.resource, path: action.path }
if (ref) owner?.open({ type: 'pageitem', ref })
})
})
@@ -834,6 +870,40 @@
}
})
// The same for agents, whose tabs are an agent's deployed page or a resource tab its
// editor took over: summaries by path, so an unvisited one is named and marked as the
// editor side before its editor stamps it.
const agentSummariesRequested = new Set<string>()
let agentSummaries = $state<Record<string, Record<string, string>>>({})
$effect(() => {
const ws = previewWorkspace
if (!ws || agentSummariesRequested.has(ws)) return
if (!(owner?.tabs ?? []).some((t) => agentTabPath(t) !== undefined)) return
agentSummariesRequested.add(ws)
listWorkspaceAgents(ws)
.then((agents) => {
agentSummaries = {
...agentSummaries,
[ws]: Object.fromEntries(agents.map((a) => [a.path, a.summary]))
}
})
.catch((e) => {
agentSummariesRequested.delete(ws)
console.error('Failed to load workspace agents', e)
})
})
function agentTabPath(tab: SessionPreviewTab): string | undefined {
const get = agentPagePath(tab.loc)
if (get !== undefined) return get
const ref = parsePageItemRoute(tab.loc)
return ref?.kind === 'resource' ? ref.path : undefined
}
function listedAgentSummary(tab: SessionPreviewTab, workspace: string): string | undefined {
if (tab.editorNamed) return undefined
const path = agentTabPath(tab)
return path === undefined ? undefined : agentSummaries[workspace]?.[path]
}
// Keyed by the tab's OWN session workspace, never the active one: every warm
// session's tabs are labelled here, and two sessions on different forks can
// hold the same item path.
@@ -857,11 +927,12 @@
return (
tab.friendlyLabel ??
(listed && itemDisplayName(listed.path, listed.draftPath, listed.summary)) ??
(listedAgentSummary(tab, workspace) || undefined) ??
previewLocationLabel(tab.loc)
)
}
function tabLabelFor(tab: SessionPreviewTab, workspace: string): string {
return tabNameFor(tab, workspace) + sideSuffix(tab.url)
return tabNameFor(tab, workspace) + sideSuffix(tab, workspace)
}
// Hover title for a tab. A summary label is free text the strip truncates, and
+12 -6
View File
@@ -67,11 +67,12 @@ value:
## AI Agent Modules
An `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped
object with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server
tool, `websearch` for web search, `question` for a built-in `ask_question` tool (the agent stops,
without holding a worker, until someone answers from the run page or the chat; optional
`value.timeout` in seconds, default 1800). Set `requires_approval: true` on a `flowmodule` or `mcp`
tool to have a person approve or reject each of its calls before it runs.
object with an extra `value.tool_type`: `flowmodule` for a script or inline script tool (or a nested
agent or AI decision), `mcp` for an MCP server tool, `websearch` for web search, `question` for a
built-in `ask_question` tool (the agent stops, without holding a worker, until someone answers from
the run page or the chat; optional `value.timeout` in seconds, default 1800). Set
`requires_approval: true` on a `flowmodule` or `mcp` tool to have a person approve or reject each of
its calls before it runs.
```json
{
@@ -98,7 +99,7 @@ tool to have a person approve or reject each of its calls before it runs.
"type": "rawscript",
"language": "bun",
"content": "export async function main(query: string) { return ['doc1', 'doc2']; }",
"input_transforms": { "query": { "type": "static", "value": "" } }
"input_transforms": { "query": { "type": "ai" } }
}
}
]
@@ -106,6 +107,11 @@ tool to have a person approve or reject each of its calls before it runs.
}
```
- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script
that runs the flow
- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the
model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix
what the model must not choose, such as a resource, a table name or a limit
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its
+1
View File
@@ -2,6 +2,7 @@ export * from './prompts';
export declare function getScriptPrompt(language: string): string;
export declare function getFlowPrompt(): string;
export declare function getResourcePrompt(): string;
export declare function getAgentPrompt(): string;
export declare function getRawAppPrompt(language?: string): string;
export declare function getPipelinePrompt(): string;
export declare function getDatatablePrompt(): string;
+7
View File
@@ -51,6 +51,13 @@ export function getResourcePrompt(): string {
].filter(Boolean).join('\n\n');
}
// Helper for AI agent authoring
export function getAgentPrompt(): string {
return [
prompts.AGENT_BASE
].filter(Boolean).join('\n\n');
}
// Helper for raw app authoring (chat consumers). Inline backend runnables are
// ordinary Windmill jobs, so the reference has to carry the SDK the runnable
// calls — without it an agent invents client functions and hand-rolls HTTP.
+1
View File
@@ -3,6 +3,7 @@
export declare const SCRIPT_BASE: string;
export declare const FLOW_BASE: string;
export declare const RESOURCES_BASE: string;
export declare const AGENT_BASE: string;
export declare const RAW_APP_BASE: string;
export declare const PIPELINE_BASE: string;
export declare const DATATABLE_BASE: string;
+123 -6
View File
@@ -96,11 +96,12 @@ value:
## AI Agent Modules
An \`aiagent\` module runs an LLM that can call tools. Each entry of \`value.tools\` is a module-shaped
object with an extra \`value.tool_type\`: \`flowmodule\` for a script/flow tool, \`mcp\` for an MCP server
tool, \`websearch\` for web search, \`question\` for a built-in \`ask_question\` tool (the agent stops,
without holding a worker, until someone answers from the run page or the chat; optional
\`value.timeout\` in seconds, default 1800). Set \`requires_approval: true\` on a \`flowmodule\` or \`mcp\`
tool to have a person approve or reject each of its calls before it runs.
object with an extra \`value.tool_type\`: \`flowmodule\` for a script or inline script tool (or a nested
agent or AI decision), \`mcp\` for an MCP server tool, \`websearch\` for web search, \`question\` for a
built-in \`ask_question\` tool (the agent stops, without holding a worker, until someone answers from
the run page or the chat; optional \`value.timeout\` in seconds, default 1800). Set
\`requires_approval: true\` on a \`flowmodule\` or \`mcp\` tool to have a person approve or reject each of
its calls before it runs.
\`\`\`json
{
@@ -127,7 +128,7 @@ tool to have a person approve or reject each of its calls before it runs.
"type": "rawscript",
"language": "bun",
"content": "export async function main(query: string) { return ['doc1', 'doc2']; }",
"input_transforms": { "query": { "type": "static", "value": "" } }
"input_transforms": { "query": { "type": "ai" } }
}
}
]
@@ -135,6 +136,11 @@ tool to have a person approve or reject each of its calls before it runs.
}
\`\`\`
- A flow cannot be a tool: a run offered one fails with \`Unsupported tool\`. Wrap it in a script
that runs the flow
- Each argument of a tool is either the model's or fixed. \`{ "type": "ai" }\` (or no value) lets the
model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix
what the model must not choose, such as a resource, a table name or a limit
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved
agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its
@@ -921,6 +927,117 @@ def main(db: postgresql):
`;
export const AGENT_BASE = `# Windmill AI Agents
An agent, as the Agents page lists it, is a saved LLM with its own system prompt, tools and
memory, stored as a resource of type \`ai_agent\`. It is built once and used in several places: it is
run from its own page (a chat, or a run form), a flow's AI agent step links to it by path, and it
can be shared in Slack channels from its page. Build a standalone agent when the user wants an
assistant they talk to or reuse. Build a flow when the agent is one step of a larger process.
## Structure
\`\`\`json
{
"resource_type": "ai_agent",
"description": "Answers support questions from the product docs",
"value": {
"provider": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" },
"system_prompt": "You are the support assistant for Acme. Answer from the docs; say so when they don't cover a question.",
"memory": { "kind": "compaction" },
"output_type": "text",
"tools": [
{
"id": "search_docs",
"summary": "search_documentation",
"description": "Search the product documentation. Use it whenever the user asks how a feature works.",
"value": {
"tool_type": "flowmodule",
"type": "script",
"path": "f/support/search_docs",
"input_transforms": {
"query": { "type": "ai" },
"index": { "type": "static", "value": "product_docs" }
}
}
}
]
}
}
\`\`\`
The agent's own settings are plain values, not the \`input_transforms\` an agent step in a flow uses:
each is fixed when the agent is saved. Only a tool's arguments keep \`input_transforms\` (see Tools). \`$res:\` and \`$var:\` references inside it are resolved at run time.
- \`provider\` (required): \`{ "kind", "resource": "$res:<path>", "model" }\`, plus an optional
\`reasoning_effort\`. \`resource\` names the workspace's AI provider resource of that kind
- \`system_prompt\`: how the agent behaves. Write it for the agent's job: who it serves, what it may
do with its tools, and what it should refuse or hand back
- \`memory\`: what lets the agent see earlier turns. \`{ "kind": "compaction" }\` keeps the whole
conversation, summarizing its older part as it nears the model's context window; it is what a new
agent starts with on its page. \`{ "kind": "off" }\` makes every run start from nothing, right for an
agent that handles one request per run
- \`output_type\`: \`text\` (default) or \`image\`. Image output ignores tools and needs S3 storage on the
workspace
- \`output_schema\`: a JSON schema the answer must follow, for an agent whose output a program reads
- \`max_iterations\`: how many model and tool rounds a run may take, 1 to 1000 (default 10)
- \`streaming\` (default on), \`temperature\`, \`max_completion_tokens\`: as their names say
- \`run_on_behalf_of\`: runs from the agent's page take the identity the agent was deployed with
rather than the caller's, so people can use it without access to what its tools touch.
\`slack_anyone\` lets anyone in the Slack channels it is shared in ask it, and is kept only with
\`run_on_behalf_of\`. Set either only when the user asks: both widen who acts with the deployer's
rights
- \`on_behalf_of\` is the identity itself. Deploying sets it, so never write it
## Tools
\`tools\` takes the same entries as a flow's AI agent step. \`value.tool_type\` is \`flowmodule\` for a
script or inline script the agent calls (or a nested agent or AI decision), \`mcp\` for an MCP server
(\`{ "tool_type": "mcp", "resource_path": "$res:<path>", "include_tools": [], "exclude_tools": [] }\`),
or \`websearch\` for web search (\`{ "tool_type": "websearch" }\`, summary \`Web Search\`).
- Write logic made for this agent as an inline \`rawscript\` tool, as a flow writes its steps. Point a
\`script\` tool at a deployed script (\`path\`) only when one already does the job: a run calls the
deployed version, so a script that exists only as a draft fails the run. An inline tool's
\`value\` is a flow's rawscript step:
\`{ "tool_type": "flowmodule", "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { ... }", "input_transforms": { "query": { "type": "ai" } } }\`
- A flow cannot be a tool: a run offered one fails with \`Unsupported tool\`. Wrap it in a script
that runs the flow
- A flowmodule tool's \`summary\` is the name the agent calls it by: letters, numbers and underscores
only, unique among the agent's tools, and neither a reserved step id (a JavaScript keyword, \`ctx\`,
\`state\`, \`bg\`) nor \`__wm_web_search\`. Put the explanation in \`description\`, which tells the agent
when to call it
- Each argument of a tool is either the model's or fixed. \`{ "type": "ai" }\` (or no value) lets the
model fill it; a static value fixes it and hides it from the model. Fix what the model must not
choose, such as a resource, a table name or a limit
## Using It From a Flow
An AI agent step links to a saved agent with \`value.agent\` instead of carrying its own \`provider\`,
system prompt and tools:
\`\`\`json
{
"id": "support",
"value": {
"type": "aiagent",
"agent": "f/support/support_agent",
"input_transforms": {
"user_message": { "type": "javascript", "expr": "flow_input.question" }
},
"tools": []
}
}
\`\`\`
The step holds only what belongs to one use of the agent: \`user_message\`, \`user_attachments\`,
\`enabled_tools\`, \`memory_id\`, \`previous_messages\`, and the \`model\` and \`reasoning_effort\` overrides.
Its \`value.tool_inputs\` (\`{ "<tool id>": { "<argument>": <input transform> } }\`) wires a saved
tool's arguments to this flow, e.g. \`flow_input.customer_id\`, over the transforms the agent saved,
without changing the agent. Everything else comes from the agent, so a change to the agent reaches
every flow linking it.
`;
export const RAW_APP_BASE = `# Windmill Raw Apps
Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.
@@ -0,0 +1,119 @@
---
name: agents
description: MUST use when creating or changing an AI agent, the reusable `ai_agent` resource the Agents page lists.
---
# Windmill AI Agents
An agent, as the Agents page lists it, is a saved LLM with its own system prompt, tools and
memory, stored as a resource of type `ai_agent`. It is built once and used in several places: it is
run from its own page (a chat, or a run form), a flow's AI agent step links to it by path, and it
can be shared in Slack channels from its page. Build a standalone agent when the user wants an
assistant they talk to or reuse. Build a flow when the agent is one step of a larger process.
## File Format
An agent is a resource file like any other, `{path}.resource.yaml` (or `.resource.json`), with
`resource_type: ai_agent`.
## Structure
```json
{
"resource_type": "ai_agent",
"description": "Answers support questions from the product docs",
"value": {
"provider": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" },
"system_prompt": "You are the support assistant for Acme. Answer from the docs; say so when they don't cover a question.",
"memory": { "kind": "compaction" },
"output_type": "text",
"tools": [
{
"id": "search_docs",
"summary": "search_documentation",
"description": "Search the product documentation. Use it whenever the user asks how a feature works.",
"value": {
"tool_type": "flowmodule",
"type": "script",
"path": "f/support/search_docs",
"input_transforms": {
"query": { "type": "ai" },
"index": { "type": "static", "value": "product_docs" }
}
}
}
]
}
}
```
The agent's own settings are plain values, not the `input_transforms` an agent step in a flow uses:
each is fixed when the agent is saved. Only a tool's arguments keep `input_transforms` (see Tools). `$res:` and `$var:` references inside it are resolved at run time.
- `provider` (required): `{ "kind", "resource": "$res:<path>", "model" }`, plus an optional
`reasoning_effort`. `resource` names the workspace's AI provider resource of that kind
- `system_prompt`: how the agent behaves. Write it for the agent's job: who it serves, what it may
do with its tools, and what it should refuse or hand back
- `memory`: what lets the agent see earlier turns. `{ "kind": "compaction" }` keeps the whole
conversation, summarizing its older part as it nears the model's context window; it is what a new
agent starts with on its page. `{ "kind": "off" }` makes every run start from nothing, right for an
agent that handles one request per run
- `output_type`: `text` (default) or `image`. Image output ignores tools and needs S3 storage on the
workspace
- `output_schema`: a JSON schema the answer must follow, for an agent whose output a program reads
- `max_iterations`: how many model and tool rounds a run may take, 1 to 1000 (default 10)
- `streaming` (default on), `temperature`, `max_completion_tokens`: as their names say
- `run_on_behalf_of`: runs from the agent's page take the identity the agent was deployed with
rather than the caller's, so people can use it without access to what its tools touch.
`slack_anyone` lets anyone in the Slack channels it is shared in ask it, and is kept only with
`run_on_behalf_of`. Set either only when the user asks: both widen who acts with the deployer's
rights
- `on_behalf_of` is the identity itself. Deploying sets it, so never write it
## Tools
`tools` takes the same entries as a flow's AI agent step. `value.tool_type` is `flowmodule` for a
script or inline script the agent calls (or a nested agent or AI decision), `mcp` for an MCP server
(`{ "tool_type": "mcp", "resource_path": "$res:<path>", "include_tools": [], "exclude_tools": [] }`),
or `websearch` for web search (`{ "tool_type": "websearch" }`, summary `Web Search`).
- Write logic made for this agent as an inline `rawscript` tool, as a flow writes its steps. Point a
`script` tool at a deployed script (`path`) only when one already does the job: a run calls the
deployed version, so a script that exists only as a draft fails the run. An inline tool's
`value` is a flow's rawscript step:
`{ "tool_type": "flowmodule", "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { ... }", "input_transforms": { "query": { "type": "ai" } } }`
- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script
that runs the flow
- A flowmodule tool's `summary` is the name the agent calls it by: letters, numbers and underscores
only, unique among the agent's tools, and neither a reserved step id (a JavaScript keyword, `ctx`,
`state`, `bg`) nor `__wm_web_search`. Put the explanation in `description`, which tells the agent
when to call it
- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the
model fill it; a static value fixes it and hides it from the model. Fix what the model must not
choose, such as a resource, a table name or a limit
## Using It From a Flow
An AI agent step links to a saved agent with `value.agent` instead of carrying its own `provider`,
system prompt and tools:
```json
{
"id": "support",
"value": {
"type": "aiagent",
"agent": "f/support/support_agent",
"input_transforms": {
"user_message": { "type": "javascript", "expr": "flow_input.question" }
},
"tools": []
}
}
```
The step holds only what belongs to one use of the agent: `user_message`, `user_attachments`,
`enabled_tools`, `memory_id`, `previous_messages`, and the `model` and `reasoning_effort` overrides.
Its `value.tool_inputs` (`{ "<tool id>": { "<argument>": <input transform> } }`) wires a saved
tool's arguments to this flow, e.g. `flow_input.customer_id`, over the transforms the agent saved,
without changing the agent. Everything else comes from the agent, so a change to the agent reaches
every flow linking it.
@@ -39,6 +39,7 @@ Example: `f/databases/postgres_prod.resource.json`
An agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the
agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by
linking its path.
Before writing one, use the `agents` skill.
## Variable References
@@ -154,11 +154,12 @@ value:
## AI Agent Modules
An `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped
object with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server
tool, `websearch` for web search, `question` for a built-in `ask_question` tool (the agent stops,
without holding a worker, until someone answers from the run page or the chat; optional
`value.timeout` in seconds, default 1800). Set `requires_approval: true` on a `flowmodule` or `mcp`
tool to have a person approve or reject each of its calls before it runs.
object with an extra `value.tool_type`: `flowmodule` for a script or inline script tool (or a nested
agent or AI decision), `mcp` for an MCP server tool, `websearch` for web search, `question` for a
built-in `ask_question` tool (the agent stops, without holding a worker, until someone answers from
the run page or the chat; optional `value.timeout` in seconds, default 1800). Set
`requires_approval: true` on a `flowmodule` or `mcp` tool to have a person approve or reject each of
its calls before it runs.
```json
{
@@ -185,7 +186,7 @@ tool to have a person approve or reject each of its calls before it runs.
"type": "rawscript",
"language": "bun",
"content": "export async function main(query: string) { return ['doc1', 'doc2']; }",
"input_transforms": { "query": { "type": "static", "value": "" } }
"input_transforms": { "query": { "type": "ai" } }
}
}
]
@@ -193,6 +194,11 @@ tool to have a person approve or reject each of its calls before it runs.
}
```
- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script
that runs the flow
- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the
model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix
what the model must not choose, such as a resource, a table name or a limit
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its
+116
View File
@@ -0,0 +1,116 @@
# Windmill AI Agents
An agent, as the Agents page lists it, is a saved LLM with its own system prompt, tools and
memory, stored as a resource of type `ai_agent`. It is built once and used in several places: it is
run from its own page (a chat, or a run form), a flow's AI agent step links to it by path, and it
can be shared in Slack channels from its page. Build a standalone agent when the user wants an
assistant they talk to or reuse. Build a flow when the agent is one step of a larger process.
<!-- cli-only -->
## File Format
An agent is a resource file like any other, `{path}.resource.yaml` (or `.resource.json`), with
`resource_type: ai_agent`.
<!-- /cli-only -->
## Structure
```json
{
"resource_type": "ai_agent",
"description": "Answers support questions from the product docs",
"value": {
"provider": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" },
"system_prompt": "You are the support assistant for Acme. Answer from the docs; say so when they don't cover a question.",
"memory": { "kind": "compaction" },
"output_type": "text",
"tools": [
{
"id": "search_docs",
"summary": "search_documentation",
"description": "Search the product documentation. Use it whenever the user asks how a feature works.",
"value": {
"tool_type": "flowmodule",
"type": "script",
"path": "f/support/search_docs",
"input_transforms": {
"query": { "type": "ai" },
"index": { "type": "static", "value": "product_docs" }
}
}
}
]
}
}
```
The agent's own settings are plain values, not the `input_transforms` an agent step in a flow uses:
each is fixed when the agent is saved. Only a tool's arguments keep `input_transforms` (see Tools). `$res:` and `$var:` references inside it are resolved at run time.
- `provider` (required): `{ "kind", "resource": "$res:<path>", "model" }`, plus an optional
`reasoning_effort`. `resource` names the workspace's AI provider resource of that kind
- `system_prompt`: how the agent behaves. Write it for the agent's job: who it serves, what it may
do with its tools, and what it should refuse or hand back
- `memory`: what lets the agent see earlier turns. `{ "kind": "compaction" }` keeps the whole
conversation, summarizing its older part as it nears the model's context window; it is what a new
agent starts with on its page. `{ "kind": "off" }` makes every run start from nothing, right for an
agent that handles one request per run
- `output_type`: `text` (default) or `image`. Image output ignores tools and needs S3 storage on the
workspace
- `output_schema`: a JSON schema the answer must follow, for an agent whose output a program reads
- `max_iterations`: how many model and tool rounds a run may take, 1 to 1000 (default 10)
- `streaming` (default on), `temperature`, `max_completion_tokens`: as their names say
- `run_on_behalf_of`: runs from the agent's page take the identity the agent was deployed with
rather than the caller's, so people can use it without access to what its tools touch.
`slack_anyone` lets anyone in the Slack channels it is shared in ask it, and is kept only with
`run_on_behalf_of`. Set either only when the user asks: both widen who acts with the deployer's
rights
- `on_behalf_of` is the identity itself. Deploying sets it, so never write it
## Tools
`tools` takes the same entries as a flow's AI agent step. `value.tool_type` is `flowmodule` for a
script or inline script the agent calls (or a nested agent or AI decision), `mcp` for an MCP server
(`{ "tool_type": "mcp", "resource_path": "$res:<path>", "include_tools": [], "exclude_tools": [] }`),
or `websearch` for web search (`{ "tool_type": "websearch" }`, summary `Web Search`).
- Write logic made for this agent as an inline `rawscript` tool, as a flow writes its steps. Point a
`script` tool at a deployed script (`path`) only when one already does the job: a run calls the
deployed version, so a script that exists only as a draft fails the run. An inline tool's
`value` is a flow's rawscript step:
`{ "tool_type": "flowmodule", "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { ... }", "input_transforms": { "query": { "type": "ai" } } }`
- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script
that runs the flow
- A flowmodule tool's `summary` is the name the agent calls it by: letters, numbers and underscores
only, unique among the agent's tools, and neither a reserved step id (a JavaScript keyword, `ctx`,
`state`, `bg`) nor `__wm_web_search`. Put the explanation in `description`, which tells the agent
when to call it
- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the
model fill it; a static value fixes it and hides it from the model. Fix what the model must not
choose, such as a resource, a table name or a limit
## Using It From a Flow
An AI agent step links to a saved agent with `value.agent` instead of carrying its own `provider`,
system prompt and tools:
```json
{
"id": "support",
"value": {
"type": "aiagent",
"agent": "f/support/support_agent",
"input_transforms": {
"user_message": { "type": "javascript", "expr": "flow_input.question" }
},
"tools": []
}
}
```
The step holds only what belongs to one use of the agent: `user_message`, `user_attachments`,
`enabled_tools`, `memory_id`, `previous_messages`, and the `model` and `reasoning_effort` overrides.
Its `value.tool_inputs` (`{ "<tool id>": { "<argument>": <input transform> } }`) wires a saved
tool's arguments to this flow, e.g. `flow_input.customer_id`, over the transforms the agent saved,
without changing the agent. Everything else comes from the agent, so a change to the agent reaches
every flow linking it.
+12 -6
View File
@@ -67,11 +67,12 @@ value:
## AI Agent Modules
An `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped
object with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server
tool, `websearch` for web search, `question` for a built-in `ask_question` tool (the agent stops,
without holding a worker, until someone answers from the run page or the chat; optional
`value.timeout` in seconds, default 1800). Set `requires_approval: true` on a `flowmodule` or `mcp`
tool to have a person approve or reject each of its calls before it runs.
object with an extra `value.tool_type`: `flowmodule` for a script or inline script tool (or a nested
agent or AI decision), `mcp` for an MCP server tool, `websearch` for web search, `question` for a
built-in `ask_question` tool (the agent stops, without holding a worker, until someone answers from
the run page or the chat; optional `value.timeout` in seconds, default 1800). Set
`requires_approval: true` on a `flowmodule` or `mcp` tool to have a person approve or reject each of
its calls before it runs.
```json
{
@@ -98,7 +99,7 @@ tool to have a person approve or reject each of its calls before it runs.
"type": "rawscript",
"language": "bun",
"content": "export async function main(query: string) { return ['doc1', 'doc2']; }",
"input_transforms": { "query": { "type": "static", "value": "" } }
"input_transforms": { "query": { "type": "ai" } }
}
}
]
@@ -106,6 +107,11 @@ tool to have a person approve or reject each of its calls before it runs.
}
```
- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script
that runs the flow
- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the
model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix
what the model must not choose, such as a resource, a table name or a limit
- `provider` is an object, not a bare resource string: `{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its
+3
View File
@@ -36,6 +36,9 @@ Example: `f/databases/postgres_prod.resource.json`
An agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the
agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by
linking its path.
<!-- cli-only -->
Before writing one, use the `agents` skill.
<!-- /cli-only -->
## Variable References
+13
View File
@@ -1683,6 +1683,12 @@ TOPICS: dict[str, dict] = {
'chat_helper': 'getResourcePrompt',
'parts': ['resources.md'],
},
'agent': {
'skill': 'agents',
'description': 'MUST use when creating or changing an AI agent, the reusable `ai_agent` resource the Agents page lists.',
'chat_helper': 'getAgentPrompt',
'parts': ['agent.md'],
},
'workflow_as_code': {
'skill': 'write-workflow-as-code',
'description': 'MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow.',
@@ -1725,6 +1731,7 @@ CHAT_CONSTANTS = {
'script-base.md': 'SCRIPT_BASE',
'flow-base.md': 'FLOW_BASE',
'resources.md': 'RESOURCES_BASE',
'agent.md': 'AGENT_BASE',
'raw-app.md': 'RAW_APP_BASE',
'pipeline-base.md': 'PIPELINE_BASE',
'datatable.md': 'DATATABLE_BASE',
@@ -2647,6 +2654,11 @@ export function getResourcePrompt(): string {
__RETURN_resources__
}
// Helper for AI agent authoring
export function getAgentPrompt(): string {
__RETURN_agent__
}
// Helper for raw app authoring (chat consumers). Inline backend runnables are
// ordinary Windmill jobs, so the reference has to carry the SDK the runnable
// calls — without it an agent invents client functions and hand-rolls HTTP.
@@ -2733,6 +2745,7 @@ export function getWorkflowAsCodePrompt(language?: string): string {
export declare function getScriptPrompt(language: string): string;
export declare function getFlowPrompt(): string;
export declare function getResourcePrompt(): string;
export declare function getAgentPrompt(): string;
export declare function getRawAppPrompt(language?: string): string;
export declare function getPipelinePrompt(): string;
export declare function getDatatablePrompt(): string;