mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 08:03:45 +00:00
feat: chat with a saved agent from a raw app through an agent runnable (#11921)
* feat(chat-sdk): chat through a raw app's backend runnable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(chat-sdk): finish a lost stream and wait out a stopped turn in streamChatTurn Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(chat-sdk): show the answer when a stream is lost before a tool call Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(chat-sdk): send attachments through the raw app runnable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(chat-sdk): point sandboxed attachments at the runnable section Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: agent runnables for raw apps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: build agent runnables without the parquet feature, cache the agent check query Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address review nits on the agent picker and the CLI schema doc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: run an agent runnable once when it is given no memory_id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: accept previous_messages on agent runs, read-checking the files it quotes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse an anonymous agent turn while one with the same memory still runs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show the agent's settings in the raw-app runnable panel, size its button like the row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: offer an agent runnable memory_id or previous_messages after the agent's memory Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: take previous_messages in a memory-off agent's run form, type agent attachments as s3object Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: file an anonymous agent turn as a conversation, so its memory is collected and its turns serialized Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: own anonymous agent conversations under a fixed name that fits created_by Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: own anonymous agent conversations as anonymous and keep them out of conversation lists Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: point file_chat_turn at the anonymous conversation owner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: title the runnable picker after what it lists, in sentence case Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: start a chat with local history listing its stored conversations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: chat through a raw app's agent runnable with { wmill, runnable } Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: key an agent runnable chat's local history by its app as well Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address review findings on agent runnables Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: offer memory_id to an agent runnable over an older auto memory Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop inherited static inputs from an agent runnable, show the memory-off note only when it is off 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:
co-authored by
Claude Opus 5.5
parent
5ef19a88a9
commit
c6a93a9b56
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "392b256dd7f36ec3736fc2f9d564dc198a6b69d62fb244691af0601e542b6ac3"
|
||||
}
|
||||
@@ -14,7 +14,7 @@ pub use windmill_common::flow_conversations::FlowConversation;
|
||||
use windmill_common::{
|
||||
db::{UserDB, DB},
|
||||
error::{JsonResult, Result},
|
||||
flow_conversations::{running_turns, MessageType, RunningTurn},
|
||||
flow_conversations::{running_turns, MessageType, RunningTurn, ANONYMOUS_CONVERSATION_OWNER},
|
||||
utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination},
|
||||
};
|
||||
|
||||
@@ -96,7 +96,8 @@ async fn list_conversations(
|
||||
"created_by",
|
||||
"is_test",
|
||||
])
|
||||
.and_where_eq("workspace_id", "?".bind(&w_id));
|
||||
.and_where_eq("workspace_id", "?".bind(&w_id))
|
||||
.and_where_ne("created_by", "?".bind(&ANONYMOUS_CONVERSATION_OWNER));
|
||||
|
||||
if let Some(flow_path) = &query.flow_path {
|
||||
sqlb.and_where_eq("flow_path", "?".bind(flow_path));
|
||||
|
||||
@@ -916,6 +916,35 @@ pub async fn handle_chat_conversation_messages(
|
||||
is_test: bool,
|
||||
// The run's args, for the files the message carried.
|
||||
args: &HashMap<String, Box<serde_json::value::RawValue>>,
|
||||
) -> error::Result<()> {
|
||||
file_chat_turn(
|
||||
tx,
|
||||
&authed.username,
|
||||
authed.is_admin,
|
||||
w_id,
|
||||
flow_path,
|
||||
run_query,
|
||||
user_message_raw,
|
||||
job_id,
|
||||
is_test,
|
||||
args,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`handle_chat_conversation_messages`] for a conversation owned by `owner`, which need not be a
|
||||
/// user: an anonymous app visitor's is owned by `ANONYMOUS_CONVERSATION_OWNER`.
|
||||
pub async fn file_chat_turn(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
owner: &str,
|
||||
is_admin: bool,
|
||||
w_id: &str,
|
||||
flow_path: &str,
|
||||
run_query: &RunJobQuery,
|
||||
user_message_raw: Option<&Box<serde_json::value::RawValue>>,
|
||||
job_id: Uuid,
|
||||
is_test: bool,
|
||||
args: &HashMap<String, Box<serde_json::value::RawValue>>,
|
||||
) -> error::Result<()> {
|
||||
// Names the query parameter rather than the field: it is not a flow argument, and
|
||||
// supplying it as one is the first thing tried on reading `memory_id is required`.
|
||||
@@ -945,8 +974,8 @@ pub async fn handle_chat_conversation_messages(
|
||||
tx,
|
||||
w_id,
|
||||
flow_path,
|
||||
&authed.username,
|
||||
authed.is_admin,
|
||||
owner,
|
||||
is_admin,
|
||||
&user_message,
|
||||
memory_id,
|
||||
is_test,
|
||||
|
||||
@@ -17107,6 +17107,12 @@ paths:
|
||||
user_attachments:
|
||||
type: array
|
||||
items: {}
|
||||
previous_messages:
|
||||
description: |
|
||||
The history to answer from, as chat messages, when the agent's memory is off.
|
||||
An agent with managed memory reads its conversation instead and ignores this.
|
||||
type: array
|
||||
items: {}
|
||||
responses:
|
||||
"201":
|
||||
description: job created
|
||||
|
||||
@@ -68,51 +68,9 @@ pub(crate) async fn run_agent_as(
|
||||
crate::jobs::check_license_key_valid().await?;
|
||||
|
||||
// Read through the caller's own permissions: reading the agent is what allows running it.
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let value = sqlx::query_scalar!(
|
||||
"SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\"
|
||||
FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
let Some(sqlx::types::Json(mut value)) = not_found_if_none(value, "Agent", path)? else {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Agent {path} has no configuration"
|
||||
)));
|
||||
};
|
||||
// Who the agent runs as and who can ask it in Slack, not inputs: its identity when it opts
|
||||
// in, as a deployed flow does.
|
||||
let obj = value.as_object_mut();
|
||||
let (agent_obo, run_obo) = obj
|
||||
.map(|o| {
|
||||
o.remove("slack_anyone");
|
||||
(o.remove("on_behalf_of"), o.remove("run_on_behalf_of"))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let agent_obo = agent_obo
|
||||
.filter(|_| run_obo.and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.and_then(|v| v.as_str().map(str::to_string));
|
||||
let config = config_to_draft(value)?;
|
||||
|
||||
// A chat turn is filed where the agent's chat lists its conversations, which a flow cannot
|
||||
// name (flow paths carry no `.`), so the two never share a conversation list.
|
||||
let chat = run_query.memory_id.is_some();
|
||||
let run_path = if chat {
|
||||
format!("{path}.chat")
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
// A step memory id would replace the conversation's, and the agent would forget the turns
|
||||
// before; history comes from the conversation alone.
|
||||
let flow_value = agent_step_flow(
|
||||
&config,
|
||||
AGENT_NODE_ID,
|
||||
chat,
|
||||
&["memory_id", "previous_messages"],
|
||||
)?;
|
||||
let AgentTurn { flow_value, run_path, agent_obo } =
|
||||
agent_turn(authed, user_db, w_id, path, chat).await?;
|
||||
|
||||
let on_behalf_of =
|
||||
windmill_common::on_behalf_of_from_permissioned_as(agent_obo.as_deref(), w_id, db).await?;
|
||||
@@ -210,11 +168,73 @@ pub(crate) async fn run_agent_as(
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
/// A turn of a saved agent, as the flow that runs it.
|
||||
pub(crate) struct AgentTurn {
|
||||
pub flow_value: windmill_common::flows::FlowValue,
|
||||
/// Where the turn is filed: a chat turn under `<path>.chat`, which a flow cannot name (flow
|
||||
/// paths carry no `.`), so the agent's conversations never share a list with a flow's.
|
||||
pub run_path: String,
|
||||
/// The identity the agent runs as when it opts in (`run_on_behalf_of`). A caller that runs it
|
||||
/// as some other identity, such as an app's, ignores it.
|
||||
pub agent_obo: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads the agent through `authed`'s own permissions and builds the flow of one of its turns.
|
||||
/// Who it may see and which inputs or slack settings it carries are not inputs of the run.
|
||||
pub(crate) async fn agent_turn(
|
||||
authed: &ApiAuthed,
|
||||
user_db: &UserDB,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
chat: bool,
|
||||
) -> Result<AgentTurn> {
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let value = sqlx::query_scalar!(
|
||||
"SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\"
|
||||
FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
let Some(sqlx::types::Json(mut value)) = not_found_if_none(value, "Agent", path)? else {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Agent {path} has no configuration"
|
||||
)));
|
||||
};
|
||||
let obj = value.as_object_mut();
|
||||
let (agent_obo, run_obo) = obj
|
||||
.map(|o| {
|
||||
o.remove("slack_anyone");
|
||||
(o.remove("on_behalf_of"), o.remove("run_on_behalf_of"))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let agent_obo = agent_obo
|
||||
.filter(|_| run_obo.and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.and_then(|v| v.as_str().map(str::to_string));
|
||||
let config = config_to_draft(value)?;
|
||||
let run_path = if chat {
|
||||
format!("{path}.chat")
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
// A step memory id would replace the conversation's, and the agent would forget the turns
|
||||
// before; history comes from the conversation alone.
|
||||
let flow_value = agent_step_flow(
|
||||
&config,
|
||||
AGENT_NODE_ID,
|
||||
chat,
|
||||
&["memory_id", "previous_messages"],
|
||||
)?;
|
||||
Ok(AgentTurn { flow_value, run_path, agent_obo })
|
||||
}
|
||||
|
||||
/// The step fetches the message's files with its job's token, so a run as the agent's identity
|
||||
/// would read any file that identity can: a caller attaches only what they could download
|
||||
/// themselves, checked as `sign_s3_objects` checks a key before signing it.
|
||||
#[cfg(all(feature = "parquet", feature = "private"))]
|
||||
async fn check_attachments_readable(
|
||||
pub(crate) async fn check_attachments_readable(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
@@ -224,14 +244,9 @@ async fn check_attachments_readable(
|
||||
get_large_file_storage, get_workspace_s3_resource_and_check_paths,
|
||||
};
|
||||
use windmill_common::db::DbWithOptAuthed;
|
||||
use windmill_types::s3::{S3Object, S3Permission};
|
||||
use windmill_types::s3::S3Permission;
|
||||
|
||||
let Some(raw) = args.get("user_attachments") else {
|
||||
return Ok(());
|
||||
};
|
||||
let attachments: Option<Vec<S3Object>> = serde_json::from_str(raw.get())
|
||||
.map_err(|e| Error::BadRequest(format!("invalid user_attachments: {e}")))?;
|
||||
for attachment in attachments.iter().flatten().filter(|a| !a.s3.is_empty()) {
|
||||
for attachment in run_attachments(args)? {
|
||||
// The path check is skipped, not failed, when the storage does not resolve.
|
||||
if get_large_file_storage(db, w_id, attachment.storage.clone())
|
||||
.await?
|
||||
@@ -254,10 +269,39 @@ async fn check_attachments_readable(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file the step fetches with its job's token: the message's attachments, and the
|
||||
/// `s3_object` parts of the history a caller supplies, which the worker downloads alike.
|
||||
pub(crate) fn run_attachments(
|
||||
args: &HashMap<String, Box<RawValue>>,
|
||||
) -> Result<Vec<windmill_types::s3::S3Object>> {
|
||||
use windmill_ai::ai_types::{ContentPart, OpenAIContent, OpenAIMessage};
|
||||
|
||||
let mut files: Vec<windmill_types::s3::S3Object> = match args.get("user_attachments") {
|
||||
Some(raw) => serde_json::from_str::<Option<Vec<_>>>(raw.get())
|
||||
.map_err(|e| Error::BadRequest(format!("invalid user_attachments: {e}")))?
|
||||
.unwrap_or_default(),
|
||||
None => vec![],
|
||||
};
|
||||
if let Some(raw) = args.get("previous_messages") {
|
||||
let messages = serde_json::from_str::<Option<Vec<OpenAIMessage>>>(raw.get())
|
||||
.map_err(|e| Error::BadRequest(format!("invalid previous_messages: {e}")))?;
|
||||
for message in messages.into_iter().flatten() {
|
||||
if let Some(OpenAIContent::Parts(parts)) = message.content {
|
||||
files.extend(parts.into_iter().filter_map(|part| match part {
|
||||
ContentPart::S3Object { s3_object } => Some(s3_object),
|
||||
_ => None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
files.retain(|f| !f.s3.is_empty());
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// The agent as a one-step flow, validated by deserializing through `FlowValue` rather than
|
||||
/// trusted as raw JSON. Its own transforms are the step's, minus `without`, and the run's inputs
|
||||
/// supply the message and the attachments over the top. Always inlined, never a link to the
|
||||
/// resource: a linked step would resolve the agent when it runs, not as it was read here.
|
||||
/// supply the message, the attachments and any history over the top. Always inlined, never a link
|
||||
/// to the resource: a linked step would resolve the agent when it runs, not as it was read here.
|
||||
pub(crate) fn agent_step_flow(
|
||||
config: &AgentDraft,
|
||||
id: &str,
|
||||
@@ -271,7 +315,7 @@ pub(crate) fn agent_step_flow(
|
||||
for key in without {
|
||||
input_transforms.remove(*key);
|
||||
}
|
||||
for key in ["user_message", "user_attachments"] {
|
||||
for key in ["user_message", "user_attachments", "previous_messages"] {
|
||||
input_transforms.insert(
|
||||
key.to_string(),
|
||||
serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }),
|
||||
@@ -292,3 +336,25 @@ pub(crate) fn agent_step_flow(
|
||||
}
|
||||
Ok(serde_json::from_value(flow)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn run_attachments_include_the_files_a_supplied_history_quotes() {
|
||||
let args: HashMap<String, Box<RawValue>> = serde_json::from_value(serde_json::json!({
|
||||
"user_attachments": [{ "s3": "u/a/attached.png" }],
|
||||
"previous_messages": [
|
||||
{ "role": "user", "content": [
|
||||
{ "type": "text", "text": "look" },
|
||||
{ "type": "s3_object", "s3_object": { "s3": "u/b/quoted.png" } }
|
||||
]},
|
||||
{ "role": "assistant", "content": "ok" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let keys: Vec<_> = run_attachments(&args).unwrap().into_iter().map(|f| f.s3).collect();
|
||||
assert_eq!(keys, ["u/a/attached.png", "u/b/quoted.png"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@ fn agent_module(config: &AgentDraft) -> Result<serde_json::Value> {
|
||||
.entry("input_transforms")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
if let Some(transforms) = transforms.as_object_mut() {
|
||||
// A case carries no history: each one is asked fresh.
|
||||
transforms.remove("previous_messages");
|
||||
for key in ["user_message", "user_attachments"] {
|
||||
transforms.insert(
|
||||
key.to_string(),
|
||||
|
||||
@@ -58,8 +58,8 @@ use windmill_audit::ActionKind;
|
||||
use windmill_common::roles::{caller_capabilities, AuthorLevel};
|
||||
use windmill_common::{
|
||||
apps::{
|
||||
app_value_has_inline_script, app_value_runnable_paths, AppScriptId, ListAppQuery,
|
||||
APP_WORKSPACED_ROUTE,
|
||||
app_value_has_inline_script, app_value_runnable_paths, AppRunnableKind, AppScriptId,
|
||||
ListAppQuery, APP_WORKSPACED_ROUTE,
|
||||
},
|
||||
auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN},
|
||||
cache,
|
||||
@@ -2307,14 +2307,14 @@ pub(crate) enum BuilderAppWrite {
|
||||
}
|
||||
|
||||
/// The half of the builder-app check that needs no DB (see `docs/operator-builder-rights.md`).
|
||||
/// Returns every runnable the app can invoke, as `(is_flow, path)`, from both the policy's
|
||||
/// Returns every runnable the app can invoke, as `(kind, path)`, from both the policy's
|
||||
/// triggerables and the value's path runnables: they are not the same list.
|
||||
fn check_operator_composed_app(
|
||||
write: BuilderAppWrite,
|
||||
raw_app: bool,
|
||||
value: Option<&RawValue>,
|
||||
policy: Option<&mut Policy>,
|
||||
) -> Result<Vec<(bool, String)>> {
|
||||
) -> Result<Vec<(AppRunnableKind, String)>> {
|
||||
let verb = if write == BuilderAppWrite::Deploy {
|
||||
"deploy"
|
||||
} else {
|
||||
@@ -2325,7 +2325,7 @@ fn check_operator_composed_app(
|
||||
"Operators with builder rights can only author full-code apps".to_string(),
|
||||
));
|
||||
}
|
||||
let mut referenced: Vec<(bool, String)> = Vec::new();
|
||||
let mut referenced: Vec<(AppRunnableKind, String)> = Vec::new();
|
||||
if let Some(value) = value {
|
||||
// The text stored, NULs stripped: a stripped NUL can rename a key into one this reads.
|
||||
let value: serde_json::Value =
|
||||
@@ -2386,9 +2386,9 @@ fn check_operator_composed_app(
|
||||
Ok(referenced)
|
||||
}
|
||||
|
||||
/// The runnables a builder app's policy authorizes, as `(is_flow, path)`; refuses a policy that
|
||||
/// The runnables a builder app's policy authorizes, as `(kind, path)`; refuses a policy that
|
||||
/// pins inline code.
|
||||
fn checked_policy_runnable_paths(policy: &Policy) -> Result<Vec<(bool, String)>> {
|
||||
fn checked_policy_runnable_paths(policy: &Policy) -> Result<Vec<(AppRunnableKind, String)>> {
|
||||
// A `rawscript/<sha>` triggerable is the deployed app's authorization to run caller-supplied
|
||||
// `raw_code` hashing to it: pinning one hands a builder arbitrary code execution through an
|
||||
// app whose value passed the inline-script check. A composition-only app has none.
|
||||
@@ -2425,21 +2425,24 @@ fn checked_policy_runnable_paths(policy: &Policy) -> Result<Vec<(bool, String)>>
|
||||
std::iter::once(key.as_str())
|
||||
.chain(key.match_indices(':').map(|(i, _)| &key[i + 1..]))
|
||||
.filter_map(|candidate| {
|
||||
candidate
|
||||
.strip_prefix("script/")
|
||||
.map(|p| (false, p.to_string()))
|
||||
.or_else(|| {
|
||||
candidate
|
||||
.strip_prefix("flow/")
|
||||
.map(|p| (true, p.to_string()))
|
||||
})
|
||||
[
|
||||
("script/", AppRunnableKind::Script),
|
||||
("flow/", AppRunnableKind::Flow),
|
||||
("agent/", AppRunnableKind::Agent),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(prefix, kind)| {
|
||||
candidate
|
||||
.strip_prefix(prefix)
|
||||
.map(|p| (kind, p.to_string()))
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn refuse_hub_runnables(referenced: &[(bool, String)]) -> Result<()> {
|
||||
fn refuse_hub_runnables(referenced: &[(AppRunnableKind, String)]) -> Result<()> {
|
||||
for (_, path) in referenced {
|
||||
if path.starts_with("hub/") {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
@@ -2494,7 +2497,7 @@ async fn require_runnables_readable(
|
||||
db: &DB,
|
||||
user_db: &UserDB,
|
||||
w_id: &str,
|
||||
referenced: Vec<(bool, String)>,
|
||||
referenced: Vec<(AppRunnableKind, String)>,
|
||||
) -> Result<()> {
|
||||
if referenced.is_empty() {
|
||||
return Ok(());
|
||||
@@ -2504,26 +2507,33 @@ async fn require_runnables_readable(
|
||||
// whichever version the caller happens to see. Resolved before the transaction opens so no
|
||||
// request holds two pool connections at once.
|
||||
let mut hashes = Vec::with_capacity(referenced.len());
|
||||
for (is_flow, path) in &referenced {
|
||||
hashes.push(if *is_flow {
|
||||
None
|
||||
} else {
|
||||
for (kind, path) in &referenced {
|
||||
hashes.push(if *kind == AppRunnableKind::Script {
|
||||
windmill_common::get_latest_script_hash(db, path, w_id).await?
|
||||
} else {
|
||||
None
|
||||
});
|
||||
}
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
for ((is_flow, path), hash) in referenced.into_iter().zip(hashes) {
|
||||
let readable = if is_flow {
|
||||
sqlx::query_scalar!(
|
||||
for ((kind, path), hash) in referenced.into_iter().zip(hashes) {
|
||||
let readable = match kind {
|
||||
AppRunnableKind::Flow => sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)",
|
||||
w_id,
|
||||
path,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
match hash {
|
||||
.unwrap_or(false),
|
||||
AppRunnableKind::Agent => sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent')",
|
||||
w_id,
|
||||
path,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false),
|
||||
AppRunnableKind::Script => match hash {
|
||||
Some(hash) => sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND hash = $2)",
|
||||
w_id,
|
||||
@@ -2533,17 +2543,21 @@ async fn require_runnables_readable(
|
||||
.await?
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
},
|
||||
};
|
||||
// One message for every cause: telling "no runnable version" apart from "not readable"
|
||||
// would reveal that a path the caller cannot read exists.
|
||||
if !readable {
|
||||
return Err(Error::PermissionDenied(if is_flow {
|
||||
format!("Flow {path} does not exist or is not readable by you")
|
||||
} else {
|
||||
format!(
|
||||
return Err(Error::PermissionDenied(match kind {
|
||||
AppRunnableKind::Flow => {
|
||||
format!("Flow {path} does not exist or is not readable by you")
|
||||
}
|
||||
AppRunnableKind::Agent => {
|
||||
format!("Agent {path} does not exist or is not readable by you")
|
||||
}
|
||||
AppRunnableKind::Script => format!(
|
||||
"Script {path} does not exist, is not readable by you, or has not finished deploying"
|
||||
)
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -4529,6 +4543,7 @@ async fn execute_component(
|
||||
let previews_hub = payload.path.as_deref().is_some_and(|p| {
|
||||
p.strip_prefix("script/")
|
||||
.or_else(|| p.strip_prefix("flow/"))
|
||||
.or_else(|| p.strip_prefix("agent/"))
|
||||
.unwrap_or(p)
|
||||
.starts_with("hub/")
|
||||
});
|
||||
@@ -4545,6 +4560,7 @@ async fn execute_component(
|
||||
let runnable_path = p
|
||||
.strip_prefix("script/")
|
||||
.or_else(|| p.strip_prefix("flow/"))
|
||||
.or_else(|| p.strip_prefix("agent/"))
|
||||
.unwrap_or(p);
|
||||
require_path_read_access_for_preview(authed, &Some(runnable_path.to_string()))?;
|
||||
}
|
||||
@@ -4880,6 +4896,36 @@ async fn execute_component(
|
||||
// the app identity. Drop it; the legitimate value rides in `extra`.
|
||||
payload.args.remove("_TEMP_SCRIPT_REFS");
|
||||
|
||||
// An agent runnable takes the message and its files, plus optionally the conversation they
|
||||
// belong to, which names the agent's memory rather than an input of its run. Without it the
|
||||
// run is a single one, as on the direct agent endpoint.
|
||||
let agent_path = payload
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| p.strip_prefix("agent/"))
|
||||
.map(str::to_string);
|
||||
let agent_memory_id = if agent_path.is_some() {
|
||||
let memory_id = payload
|
||||
.args
|
||||
.remove("memory_id")
|
||||
.map(|v| {
|
||||
serde_json::from_str::<Option<String>>(v.get())
|
||||
.map_err(|_| Error::BadRequest("memory_id must be a string".to_string()))
|
||||
})
|
||||
.transpose()?
|
||||
.flatten()
|
||||
.filter(|m| !m.trim().is_empty());
|
||||
payload.args.retain(|k, _| {
|
||||
matches!(
|
||||
k.as_str(),
|
||||
"user_message" | "user_attachments" | "previous_messages"
|
||||
)
|
||||
});
|
||||
memory_id
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (mut args, job_id) = build_args(
|
||||
policy,
|
||||
policy_triggerables,
|
||||
@@ -4918,9 +4964,64 @@ async fn execute_component(
|
||||
}
|
||||
.filter(|t| !t.is_empty())
|
||||
};
|
||||
if agent_path.is_some() {
|
||||
#[cfg(feature = "enterprise")]
|
||||
crate::jobs::check_license_key_valid().await?;
|
||||
let has_attachments = !crate::agent_runs::run_attachments(&args.args)?.is_empty();
|
||||
match opt_authed.as_ref() {
|
||||
// The agent reads the files with the app's token: a viewer attaches only what they
|
||||
// could download themselves.
|
||||
Some(_viewer) if has_attachments => {
|
||||
#[cfg(all(feature = "parquet", feature = "private"))]
|
||||
crate::agent_runs::check_attachments_readable(&db, _viewer, &w_id, &args.args)
|
||||
.await?;
|
||||
}
|
||||
None if has_attachments => {
|
||||
return Err(Error::BadRequest(
|
||||
"an anonymous viewer cannot send files to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Filed under the viewer, who must therefore be kept past the push below.
|
||||
let agent_viewer = agent_path.as_ref().and(opt_authed.clone());
|
||||
let mut agent_run_path = None;
|
||||
|
||||
let component = payload.component.clone();
|
||||
let (job_payload, tag, _runnable_on_behalf_of) =
|
||||
match (payload.path, payload.raw_code, payload.id) {
|
||||
(Some(_), None, None) if agent_path.is_some() => {
|
||||
let agent = agent_path.as_deref().unwrap_or_default();
|
||||
// Read as the identity the app runs as: reading the agent is what allows running
|
||||
// it. The app's identity is also the run's, never the agent's own on-behalf-of.
|
||||
let run_as = windmill_api_auth::fetch_api_authed_from_permissioned_as(
|
||||
permissioned_as.clone(),
|
||||
email.clone(),
|
||||
&w_id,
|
||||
&db,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let turn = crate::agent_runs::agent_turn(
|
||||
&run_as,
|
||||
&user_db,
|
||||
&w_id,
|
||||
agent,
|
||||
agent_memory_id.is_some(),
|
||||
)
|
||||
.await?;
|
||||
agent_run_path = Some(turn.run_path.clone());
|
||||
(
|
||||
JobPayload::RawFlow {
|
||||
value: turn.flow_value,
|
||||
path: Some(turn.run_path),
|
||||
restarted_from: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
// flow or script:
|
||||
(Some(path), None, None) => {
|
||||
get_payload_tag_from_prefixed_path(&path, &db, &w_id).await?
|
||||
@@ -5061,6 +5162,44 @@ async fn execute_component(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let (Some(run_path), Some(memory_id)) = (agent_run_path.as_deref(), agent_memory_id) {
|
||||
// A signed-in viewer's turn is filed under them: nobody else may continue their
|
||||
// conversation. An anonymous visitor's id is derived per app so that it can name no
|
||||
// signed-in user's conversation (a bare UUID is used as is). Either way the
|
||||
// conversation's row lock refuses a turn while one runs, and retention collects its
|
||||
// memory with its messages.
|
||||
let (owner, is_admin, memory_id) = match agent_viewer.as_ref() {
|
||||
Some(viewer) => (viewer.username.clone(), viewer.is_admin, memory_id),
|
||||
None => (
|
||||
windmill_common::flow_conversations::ANONYMOUS_CONVERSATION_OWNER.to_string(),
|
||||
false,
|
||||
windmill_common::flow_conversations::memory_key(
|
||||
&w_id,
|
||||
run_path,
|
||||
&format!("anonymous:{path}:{memory_id}"),
|
||||
)
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
let run_query = RunJobQuery { memory_id: Some(memory_id), ..Default::default() };
|
||||
if let Some(key) = run_query.memory_key(&w_id, run_path) {
|
||||
crate::jobs::set_flow_memory_id(&mut tx, uuid, key).await?;
|
||||
}
|
||||
crate::jobs::file_chat_turn(
|
||||
&mut tx,
|
||||
&owner,
|
||||
is_admin,
|
||||
&w_id,
|
||||
run_path,
|
||||
&run_query,
|
||||
args.args.get("user_message"),
|
||||
uuid,
|
||||
is_preview,
|
||||
&args.args,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Apply runnable query parameters if provided
|
||||
if let Some(ref run_query) = payload.run_query_params {
|
||||
if let Some(flow_path) = flow_path.as_deref() {
|
||||
@@ -6923,7 +7062,7 @@ mod embed_token_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod operator_app_tests {
|
||||
use super::{check_operator_composed_app, BuilderAppWrite, Policy};
|
||||
use super::{check_operator_composed_app, AppRunnableKind, BuilderAppWrite, Policy};
|
||||
use windmill_common::error::Result;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
|
||||
@@ -6941,7 +7080,10 @@ mod operator_app_tests {
|
||||
}})
|
||||
}
|
||||
|
||||
fn composed_app(value: serde_json::Value, policy: &mut Policy) -> Result<Vec<(bool, String)>> {
|
||||
fn composed_app(
|
||||
value: serde_json::Value,
|
||||
policy: &mut Policy,
|
||||
) -> Result<Vec<(AppRunnableKind, String)>> {
|
||||
let value = to_raw_value(&value);
|
||||
check_operator_composed_app(BuilderAppWrite::Deploy, true, Some(&value), Some(policy))
|
||||
}
|
||||
@@ -6959,7 +7101,10 @@ mod operator_app_tests {
|
||||
let referenced = composed_app(clean.clone(), &mut policy).unwrap();
|
||||
assert_eq!(policy.sandbox, Some(true));
|
||||
// The runnables the policy authorizes are handed back for the caller to authorize.
|
||||
assert_eq!(referenced, vec![(false, "f/x/s".to_string())]);
|
||||
assert_eq!(
|
||||
referenced,
|
||||
vec![(AppRunnableKind::Script, "f/x/s".to_string())]
|
||||
);
|
||||
|
||||
// A hub reference is unreviewed code, refused like it is in a flow.
|
||||
let mut policy = builder_policy(
|
||||
@@ -6996,12 +7141,20 @@ mod operator_app_tests {
|
||||
let mut policy = builder_policy(serde_json::json!({}));
|
||||
assert_eq!(
|
||||
composed_app(path_runnable("f/x/s", "script"), &mut policy).unwrap(),
|
||||
vec![(false, "f/x/s".to_string())]
|
||||
vec![(AppRunnableKind::Script, "f/x/s".to_string())]
|
||||
);
|
||||
let mut policy = builder_policy(serde_json::json!({}));
|
||||
assert_eq!(
|
||||
composed_app(path_runnable("f/x/f", "flow"), &mut policy).unwrap(),
|
||||
vec![(true, "f/x/f".to_string())]
|
||||
vec![(AppRunnableKind::Flow, "f/x/f".to_string())]
|
||||
);
|
||||
// An agent is reported from both surfaces, for the caller to check it can read it.
|
||||
let mut policy = builder_policy(
|
||||
serde_json::json!({"a:agent/f/x/bot": {"static_inputs": {}, "one_of_inputs": {}}}),
|
||||
);
|
||||
assert_eq!(
|
||||
composed_app(path_runnable("f/x/bot", "agent"), &mut policy).unwrap(),
|
||||
vec![(AppRunnableKind::Agent, "f/x/bot".to_string())]
|
||||
);
|
||||
let mut policy = builder_policy(serde_json::json!({}));
|
||||
assert!(composed_app(path_runnable("hub/1/x", "hubscript"), &mut policy).is_err());
|
||||
@@ -7018,7 +7171,7 @@ mod operator_app_tests {
|
||||
);
|
||||
assert_eq!(
|
||||
composed_app(clean.clone(), &mut policy).unwrap(),
|
||||
vec![(false, "f/x/s".to_string())]
|
||||
vec![(AppRunnableKind::Script, "f/x/s".to_string())]
|
||||
);
|
||||
|
||||
// `Viewer` mode makes `execute_component` accept any script/flow path, triggerables or
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function main(
|
||||
const nonEmpty = (v: unknown) => typeof v === 'string' && v.length > 0
|
||||
// The prefixes `execute_component` resolves a run against; anything else is a
|
||||
// grant no run can match.
|
||||
const RUN_TYPES = ['script', 'flow', 'hubscript']
|
||||
const RUN_TYPES = ['script', 'flow', 'hubscript', 'agent']
|
||||
const malformed = Object.entries(runnables ?? {})
|
||||
.filter(([, r]) => r != null)
|
||||
.filter(([, r]) => {
|
||||
|
||||
@@ -34,14 +34,23 @@ pub fn app_value_has_inline_script(value: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every workspace runnable the app value points a component at, as `(is_flow, path)`.
|
||||
/// What a path runnable of an app runs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AppRunnableKind {
|
||||
Script,
|
||||
Flow,
|
||||
/// A saved AI agent (an `ai_agent` resource), run as one chat turn.
|
||||
Agent,
|
||||
}
|
||||
|
||||
/// Every workspace runnable the app value points a component at, as `(kind, path)`.
|
||||
///
|
||||
/// This is what the deployed bundle actually asks `execute_component` to run: it resolves a
|
||||
/// `runnable_id` against the stored `runnables` and sends `{runType}/{path}`. The policy's
|
||||
/// triggerables are a separate surface, so both have to be authorized. Anything not plain text
|
||||
/// is refused, never skipped: the editor compares `type` loosely (`["path"] == "path"`) and
|
||||
/// interpolates `path` (`["u/x"]` → `u/x`), so a skipped entry still runs.
|
||||
pub fn app_value_runnable_paths(value: &Value) -> error::Result<Vec<(bool, String)>> {
|
||||
pub fn app_value_runnable_paths(value: &Value) -> error::Result<Vec<(AppRunnableKind, String)>> {
|
||||
let bad = |msg: String| error::Error::BadRequest(msg);
|
||||
let runnables = match value.get("runnables") {
|
||||
None | Some(Value::Null) => return Ok(vec![]),
|
||||
@@ -61,12 +70,13 @@ pub fn app_value_runnable_paths(value: &Value) -> error::Result<Vec<(bool, Strin
|
||||
let Some(Value::String(path)) = runnable.get("path") else {
|
||||
return Err(bad(format!("Runnable {id} must name its path as text")));
|
||||
};
|
||||
let is_flow = match runnable.get("runType") {
|
||||
Some(Value::String(t)) if t == "flow" => true,
|
||||
Some(Value::String(t)) if t == "script" || t == "hubscript" => false,
|
||||
let kind = match runnable.get("runType") {
|
||||
Some(Value::String(t)) if t == "flow" => AppRunnableKind::Flow,
|
||||
Some(Value::String(t)) if t == "script" || t == "hubscript" => AppRunnableKind::Script,
|
||||
Some(Value::String(t)) if t == "agent" => AppRunnableKind::Agent,
|
||||
_ => return Err(bad(format!("Unsupported runType for runnable {path}"))),
|
||||
};
|
||||
out.push((is_flow, path.clone()));
|
||||
out.push((kind, path.clone()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ use crate::utils::truncate_with_ellipsis;
|
||||
/// Changing it detaches every memory stored under a string memory id.
|
||||
const MEMORY_ID_NAMESPACE: Uuid = Uuid::from_u128(0x6f1c2d4e_8a3b_5c7d_9e0f_1a2b3c4d5e6f);
|
||||
|
||||
/// Owns anonymous app visitors' agent conversations, as `created_by` names an anonymous app run.
|
||||
/// It is also a valid username, so listing conversations leaves these out: a user of that name
|
||||
/// would otherwise list every anonymous visitor's.
|
||||
pub const ANONYMOUS_CONVERSATION_OWNER: &str = "anonymous";
|
||||
|
||||
/// Memory is stored and carried in `flow_status.memory_id` as a uuid, which names the same memory
|
||||
/// wherever it is passed, as a chat conversation id must. Any other string names a memory through a
|
||||
/// name-based (v5) uuid scoped to its workspace and flow, so the same key in two flows or two
|
||||
|
||||
+46
-3
@@ -148,6 +148,45 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' })
|
||||
- **`wmill app dev`**: there is no viewer session on the dev server, so pass
|
||||
`baseUrl`, `workspace` and `token` explicitly during development.
|
||||
|
||||
### A saved agent, through an agent runnable
|
||||
|
||||
To chat with a saved AI agent (an `ai_agent` resource), give the app an **agent
|
||||
runnable**: a path runnable of type `agent` naming the agent, picked under "AI agents"
|
||||
in the editor, or in `raw_app.yaml`:
|
||||
|
||||
```yaml
|
||||
runnables:
|
||||
chat:
|
||||
type: agent
|
||||
path: f/support/agent
|
||||
```
|
||||
|
||||
Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no
|
||||
frontend SDK scope, viewers get no consent prompt and need no access to the agent.
|
||||
Name the runnable and pass the app's generated `wmill.ts`, which the chat goes through:
|
||||
|
||||
```tsx
|
||||
import { useWindmillChat } from '@windmill-labs/chat-sdk/react'
|
||||
import * as wmill from './wmill'
|
||||
|
||||
const chat = useWindmillChat({ wmill, runnable: 'chat' })
|
||||
```
|
||||
|
||||
`runnable` is checked against the runnables `wmill.ts` declares, and names the local
|
||||
history, so two agent runnables in one app keep separate conversations. Going through
|
||||
`wmill.ts` is what makes the same code work in a deployed app and under `wmill app dev`.
|
||||
|
||||
`createChat` and the assistant-ui runtime take the same options; the AI SDK transport
|
||||
does not. The runnable takes `user_message` and `memory_id`, the conversation id, which
|
||||
the agent's memory is keyed by, so it remembers earlier turns (called from `wmill.ts`
|
||||
without `memory_id`, it is a single run with no conversation). The agent needs managed
|
||||
memory: one whose memory is off takes `previous_messages` instead, the history you keep. A signed-in viewer's turns
|
||||
are filed under them, so nobody else can continue their conversations; an anonymous
|
||||
visitor's are kept apart per app. The chat makes no request of its own: history is
|
||||
`'local'` (the app's own storage), `stop()` stops following without cancelling the turn,
|
||||
a message sent while it still runs is refused with `TurnRunningError`, and attachments
|
||||
are refused.
|
||||
|
||||
## Any framework
|
||||
|
||||
`createChat` returns a store: `subscribe` calls the listener immediately and on every
|
||||
@@ -178,7 +217,7 @@ await chat.sendMessage('Hello')
|
||||
|
||||
| Option | |
|
||||
|---|---|
|
||||
| `flowPath` | Path of the deployed flow, e.g. `f/support/assistant`. Required. |
|
||||
| `flowPath` | Path of the deployed flow, e.g. `f/support/assistant`. Required unless `follow` is set. |
|
||||
| `baseUrl` | The Windmill origin. Detected inside a raw app. |
|
||||
| `workspace` | Detected inside a raw app. |
|
||||
| `token` | A token, or a function returning one (called before every request, so it can fetch a short-lived token from your backend). Omit it inside a raw app. |
|
||||
@@ -190,6 +229,8 @@ await chat.sendMessage('Hello')
|
||||
| `pollDelayMs` | How often, in ms, the server polls a running turn for the stream (Enterprise; 50 at the fastest, other servers ignore it). Unset, the server relaxes from 100 ms to 3 s over a long turn; set it when tokens must keep flowing at that pace. |
|
||||
| `onFinish`, `onError` | Called when a turn has its answer, or could not run at all. |
|
||||
| `run` | Runs the flow for a turn yourself and returns the job id, instead of the deployed flow at `flowPath` (Windmill's editor chats with an undeployed flow through a preview run this way). Pass `memory_id` = the conversation id. |
|
||||
| `follow` | With `run`: follows the job through your own client, calling `onStream` with each chunk of its result stream and resolving with its result. The chat then makes no request of its own; `runnable` sets both for a raw app's agent runnable, see [A saved agent](#a-saved-agent-through-an-agent-runnable). |
|
||||
| `runnable`, `wmill` | In a raw app, chat through the agent runnable of that name, going through the app's generated module (`import * as wmill from './wmill'`). Replaces `flowPath`, `run` and `follow`. |
|
||||
|
||||
## State
|
||||
|
||||
@@ -294,7 +335,8 @@ That store is keyed by the **Windmill user**, so it fits a viewer session or a t
|
||||
issued per user. With one token shared by every visitor of a site, all visitors would
|
||||
see each other's conversations. For that setup use `history: 'local'` (the default
|
||||
with an explicit `token`): the conversation list and messages stay in the browser's
|
||||
`localStorage`, per Windmill instance, workspace and flow. `'none'` keeps nothing
|
||||
`localStorage`, per Windmill instance, workspace and flow, and the chat starts with that
|
||||
list in `state.conversations`, without a `loadConversations()` call. `'none'` keeps nothing
|
||||
beyond the page.
|
||||
|
||||
When the default `'server'` mode turns out unreadable (a token or sandboxed app
|
||||
@@ -333,7 +375,8 @@ custom integrations.
|
||||
When asked to add a chat over a Windmill flow: the flow must be deployed with chat mode
|
||||
on. Pick the entry point from the table at the top (`useChat` → `@windmill-labs/chat-sdk/ai-sdk`,
|
||||
assistant-ui → `@windmill-labs/chat-sdk/assistant-ui`, otherwise `@windmill-labs/chat-sdk/react`). Inside a
|
||||
Windmill raw app pass only `flowPath`. Elsewhere pass `baseUrl`, `workspace` and a
|
||||
Windmill raw app pass only `flowPath`; for a saved agent, use an `agent` runnable and pass
|
||||
`{ wmill, runnable }`. Elsewhere pass `baseUrl`, `workspace` and a
|
||||
`token`; for a public page use a token scoped to `jobs:run:flows:<flowPath>` and leave
|
||||
`history` at its default. Render `role`, `content`, `pending`, `success` and
|
||||
`tool.status`; never build the SSE handling yourself.
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@assistant-ui/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useWindmillChat } from './react'
|
||||
import type { ChatMessage, ChatOptions } from './types'
|
||||
import type { ChatMessage, RawAppWmill, TypedChatOptions } from './types'
|
||||
import { parseJsonOr } from './utils'
|
||||
|
||||
/** One assistant turn: the assistant and tool rows between two user messages. */
|
||||
@@ -17,7 +17,7 @@ export interface WindmillTurn {
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
export interface WindmillRuntimeOptions extends ChatOptions {
|
||||
export type WindmillRuntimeOptions<W extends RawAppWmill = RawAppWmill> = TypedChatOptions<W> & {
|
||||
/** Load the conversation list on mount so `ThreadListPrimitive` has something to show. Default true. */
|
||||
threadList?: boolean
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export interface WindmillRuntimeOptions extends ChatOptions {
|
||||
* Conversations become threads, so the thread list primitives switch, create and
|
||||
* delete Windmill conversations.
|
||||
*/
|
||||
export function useWindmillRuntime(options: WindmillRuntimeOptions): AssistantRuntime {
|
||||
export function useWindmillRuntime<W extends RawAppWmill>(options: WindmillRuntimeOptions<W>): AssistantRuntime {
|
||||
const chat = useWindmillChat(options)
|
||||
const turns = useMemo(() => groupTurns(chat.messages), [chat.messages])
|
||||
const withThreadList = options.threadList !== false
|
||||
|
||||
@@ -88,10 +88,9 @@ export async function uploadAttachments(
|
||||
attachments.map(async (attachment, index) => {
|
||||
try {
|
||||
const blob = attachmentBlob(attachment)
|
||||
const filename = storedAttachmentName(
|
||||
attachment.name || `attachment-${index + 1}`,
|
||||
blob.type
|
||||
)
|
||||
// The last segment only: the name may come from whoever sent the file.
|
||||
const name = attachment.name?.split(/[\\/]/).pop()?.replace(/^\.+$/, '')
|
||||
const filename = storedAttachmentName(name || `attachment-${index + 1}`, blob.type)
|
||||
const { file_key } = await api.uploadFile(`${prefix}/${index}/${filename}`, blob, {
|
||||
contentType: blob.type,
|
||||
signal: batch.signal
|
||||
|
||||
+35
-6
@@ -11,11 +11,13 @@ import { resolveConfig, type ResolvedConfig } from './config'
|
||||
import { followJob } from './follow'
|
||||
import { createLocalHistory, type LocalHistory } from './history'
|
||||
import { uploadAttachments } from './attachments'
|
||||
import type { AgentStreamEvent } from './stream'
|
||||
import { createStreamEventParser, type AgentStreamEvent } from './stream'
|
||||
import type {
|
||||
Chat,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
RawAppWmill,
|
||||
TypedChatOptions,
|
||||
ChatState,
|
||||
Conversation,
|
||||
SendMessageOptions,
|
||||
@@ -62,7 +64,7 @@ interface Turn {
|
||||
streamedText: boolean
|
||||
}
|
||||
|
||||
export function createChat(options: ChatOptions): Chat {
|
||||
export function createChat<W extends RawAppWmill>(options: TypedChatOptions<W>): Chat {
|
||||
return new ChatImpl(options)
|
||||
}
|
||||
|
||||
@@ -102,7 +104,9 @@ class ChatImpl implements Chat {
|
||||
messages: [],
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
conversations: [],
|
||||
// Browser history is read synchronously, so a chat starts with its list as server history
|
||||
// cannot: an app that never calls `loadConversations` still shows its conversations.
|
||||
conversations: this.#config.history === 'local' ? this.#local.listConversations() : [],
|
||||
history: this.#config.history,
|
||||
loadingMessages: false,
|
||||
hasMoreMessages: false
|
||||
@@ -134,6 +138,9 @@ class ChatImpl implements Chat {
|
||||
if (attachments.length > 0 && !attachmentsInput) {
|
||||
throw new Error('windmill-chat: attachments need `attachmentsInput`, the flow input that takes them')
|
||||
}
|
||||
if (attachments.length > 0 && this.#config.follow) {
|
||||
throw new Error('windmill-chat: attachments upload with the SDK\'s own requests, which `follow` replaces')
|
||||
}
|
||||
if (attachmentsInput && !attachmentsInput.multiple && attachments.length > 1) {
|
||||
// Uploading all of them would run with the first and leave the rest stranded in storage.
|
||||
throw new Error(`windmill-chat: \`${attachmentsInput.name}\` holds one file; got ${attachments.length}`)
|
||||
@@ -338,7 +345,7 @@ class ChatImpl implements Chat {
|
||||
this.#set({ messages: finalized(this.#state.messages), status: 'idle' })
|
||||
this.#persistLocal()
|
||||
}
|
||||
if (turn.jobId) {
|
||||
if (turn.jobId && !this.#config.follow) {
|
||||
// Needs `jobs:write` on a token; the stream is closed either way.
|
||||
await this.#api.cancelJob(turn.jobId).catch(() => {})
|
||||
}
|
||||
@@ -577,6 +584,27 @@ class ChatImpl implements Chat {
|
||||
|
||||
async #follow(turn: Turn, onStreamStart: () => void): Promise<unknown> {
|
||||
let started = false
|
||||
const { follow } = this.#config
|
||||
if (follow) {
|
||||
const { signal } = turn.controller
|
||||
const parser = createStreamEventParser()
|
||||
const onStream = (chunk: string) => {
|
||||
if (signal.aborted) return
|
||||
if (!started) {
|
||||
started = true
|
||||
onStreamStart()
|
||||
}
|
||||
this.#applyEvents(turn, parser.push(chunk))
|
||||
}
|
||||
// A caller's follow may not honor the signal; the turn stops waiting on it either way.
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
if (signal.aborted) reject(abortError())
|
||||
signal.addEventListener('abort', () => reject(abortError()), { once: true })
|
||||
})
|
||||
const result = await Promise.race([follow(turn.jobId!, onStream, { signal }), aborted])
|
||||
this.#applyEvents(turn, parser.flush())
|
||||
return result
|
||||
}
|
||||
for await (const event of followJob(this.#api, turn.jobId!, { signal: turn.controller.signal })) {
|
||||
if (event.type === 'completed') {
|
||||
if (event.streamLost) this.#dropCutRound(turn)
|
||||
@@ -722,8 +750,9 @@ class ChatImpl implements Chat {
|
||||
let messages = this.#state.messages
|
||||
let failed = false
|
||||
if (isErrorResult(result)) {
|
||||
// The envelope is also a legitimate result shape; the job's own status decides.
|
||||
failed = await this.#api
|
||||
// The envelope is also a legitimate result shape; the job's own status decides, when it
|
||||
// can be read.
|
||||
failed = this.#config.follow ? true : await this.#api
|
||||
.getCompletedResult(turn.jobId!, turn.controller.signal)
|
||||
.then((r) => r.success === false)
|
||||
.catch(() => true)
|
||||
|
||||
+27
-6
@@ -1,4 +1,5 @@
|
||||
import type { ChatOptions, FetchLike, HistoryMode, StorageLike, TokenSource } from './types'
|
||||
import { rawAppRunnable } from './runnable'
|
||||
|
||||
export interface ResolvedConfig {
|
||||
flowPath: string
|
||||
@@ -15,6 +16,7 @@ export interface ResolvedConfig {
|
||||
pageSize: number
|
||||
pollDelayMs: number | undefined
|
||||
run: ChatOptions['run']
|
||||
follow: ChatOptions['follow']
|
||||
onFinish: ChatOptions['onFinish']
|
||||
onError: ChatOptions['onError']
|
||||
}
|
||||
@@ -51,13 +53,31 @@ export function detectRawApp(): RawAppContext | undefined {
|
||||
}
|
||||
|
||||
export function resolveConfig(options: ChatOptions): ResolvedConfig {
|
||||
if (!options.flowPath) throw new Error('windmill-chat: flowPath is required')
|
||||
if (options.runnable !== undefined) {
|
||||
if (!options.wmill) throw new Error('windmill-chat: `runnable` needs `wmill`, the app\'s generated module')
|
||||
if (options.flowPath || options.run || options.follow) {
|
||||
throw new Error('windmill-chat: `runnable` replaces `flowPath`, `run` and `follow`')
|
||||
}
|
||||
// Unsandboxed apps share their origin's storage, so the app's path keeps their histories apart.
|
||||
const app = (globalThis as { ctx?: { path?: unknown } }).ctx?.path
|
||||
options = {
|
||||
...options,
|
||||
...rawAppRunnable(options.wmill.backendAsync[options.runnable], options.wmill.streamJob),
|
||||
flowPath: `runnable:${typeof app === 'string' ? `${app}:` : ''}${options.runnable}`
|
||||
}
|
||||
}
|
||||
const follow = options.follow
|
||||
if (follow && !options.run) throw new Error('windmill-chat: `follow` needs `run`, which starts the job it follows')
|
||||
if (follow && options.history === 'server') {
|
||||
throw new Error('windmill-chat: server history is read with the SDK\'s own requests, which `follow` replaces')
|
||||
}
|
||||
if (!options.flowPath && !follow) throw new Error('windmill-chat: flowPath is required')
|
||||
const explicitToken = options.token !== undefined
|
||||
const detected =
|
||||
options.baseUrl && options.workspace && explicitToken ? undefined : detectRawApp()
|
||||
const baseUrl = options.baseUrl ?? detected?.baseUrl
|
||||
const workspace = options.workspace ?? detected?.workspace
|
||||
if (!baseUrl || !workspace) {
|
||||
if ((!baseUrl || !workspace) && !follow) {
|
||||
throw new Error(
|
||||
'windmill-chat: pass baseUrl and workspace. They are only detected inside a raw app: an unsandboxed one on the Windmill origin, or a sandboxed one whose policy declares frontend SDK scopes.'
|
||||
)
|
||||
@@ -66,11 +86,11 @@ export function resolveConfig(options: ChatOptions): ResolvedConfig {
|
||||
const token =
|
||||
options.token ?? (options.baseUrl === undefined || options.baseUrl === detected?.baseUrl ? detected?.token : undefined)
|
||||
return {
|
||||
flowPath: options.flowPath,
|
||||
baseUrl,
|
||||
workspace,
|
||||
flowPath: options.flowPath ?? '',
|
||||
baseUrl: baseUrl ?? '',
|
||||
workspace: workspace ?? '',
|
||||
token,
|
||||
history: options.history ?? (explicitToken ? 'local' : 'server'),
|
||||
history: options.history ?? (explicitToken || follow ? 'local' : 'server'),
|
||||
historyExplicit: options.history !== undefined,
|
||||
inputs: options.inputs ?? {},
|
||||
fetch: options.fetch,
|
||||
@@ -79,6 +99,7 @@ export function resolveConfig(options: ChatOptions): ResolvedConfig {
|
||||
pageSize: options.pageSize ?? 50,
|
||||
pollDelayMs: options.pollDelayMs,
|
||||
run: options.run,
|
||||
follow,
|
||||
onFinish: options.onFinish,
|
||||
onError: options.onError
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export type {
|
||||
Conversation,
|
||||
FetchLike,
|
||||
HistoryMode,
|
||||
RawAppWmill,
|
||||
TypedChatOptions,
|
||||
SendMessageOptions,
|
||||
StorageLike,
|
||||
TokenSource,
|
||||
|
||||
+10
-6
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
||||
import { createChat } from './chat'
|
||||
import type { Chat, ChatOptions, ChatState } from './types'
|
||||
import type { Chat, ChatOptions, ChatState, RawAppWmill, TypedChatOptions } from './types'
|
||||
|
||||
export type UseWindmillChat = ChatState &
|
||||
Pick<
|
||||
@@ -19,22 +19,23 @@ export type UseWindmillChat = ChatState &
|
||||
|
||||
/**
|
||||
* A chat on a chat-mode flow. The chat is created once per `flowPath`, `baseUrl`,
|
||||
* `workspace`, `history`, `storageKey`, credential and presence of `run`, and
|
||||
* `workspace`, `history`, `storageKey`, credential and presence of `run` and `follow`, and
|
||||
* destroyed on unmount. A credential change is a new user, whose chat must not
|
||||
* carry the previous one's state: a different token string, or a switch between no
|
||||
* token, a token string and a token function, all recreate it. A token function is
|
||||
* read through a ref on every call, so a new closure per render changes what the
|
||||
* next call runs and nothing else; pass a `storageKey` per user when local history
|
||||
* must not be shared. `run`, the callbacks and `inputs` are read the same way: the
|
||||
* must not be shared. `run`, `follow`, the callbacks and `inputs` are read the same way: the
|
||||
* latest render's values go with the next message.
|
||||
*/
|
||||
export function useWindmillChat(options: ChatOptions): UseWindmillChat {
|
||||
const latest = useRef(options)
|
||||
export function useWindmillChat<W extends RawAppWmill>(options: TypedChatOptions<W>): UseWindmillChat {
|
||||
const latest = useRef<ChatOptions>(options)
|
||||
latest.current = options
|
||||
const credential =
|
||||
typeof options.token === 'function' ? 'fn' : typeof options.token === 'string' ? `str:${options.token}` : 'none'
|
||||
// A custom runner replaces the deployed flow call, so its presence is part of what the chat is.
|
||||
const customRun = options.run !== undefined
|
||||
const customFollow = options.follow !== undefined
|
||||
const chat = useMemo(
|
||||
() =>
|
||||
createChat({
|
||||
@@ -49,11 +50,14 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat {
|
||||
}
|
||||
: options.token,
|
||||
run: customRun ? (args, turn) => (latest.current.run ?? options.run!)(args, turn) : undefined,
|
||||
follow: customFollow
|
||||
? (jobId, onStream, turn) => (latest.current.follow ?? options.follow!)(jobId, onStream, turn)
|
||||
: undefined,
|
||||
onFinish: (turn) => latest.current.onFinish?.(turn),
|
||||
onError: (error, turn) => latest.current.onError?.(error, turn)
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[options.flowPath, options.baseUrl, options.workspace, options.history, options.storageKey, credential, customRun]
|
||||
[options.flowPath, options.runnable, options.wmill, options.baseUrl, options.workspace, options.history, options.storageKey, credential, customRun, customFollow]
|
||||
)
|
||||
useEffect(() => () => chat.destroy(), [chat])
|
||||
const state = useSyncExternalStore(chat.subscribe, chat.getState, chat.getState)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { turnRunningError } from './api'
|
||||
import type { ChatOptions } from './types'
|
||||
|
||||
/**
|
||||
* `run` and `follow` over a raw app's agent runnable (a path runnable of type `agent`, naming
|
||||
* a saved AI agent), behind the `runnable` option. `memory_id` is the conversation id, the
|
||||
* agent's memory key.
|
||||
*/
|
||||
export function rawAppRunnable(
|
||||
// The generated signature is the runnable's own.
|
||||
start: (args: any) => Promise<string>,
|
||||
streamJob: (jobId: string, onUpdate?: (update: { new_result_stream?: string }) => void) => Promise<unknown>
|
||||
): Required<Pick<ChatOptions, 'run' | 'follow'>> {
|
||||
return {
|
||||
run: async (args, { conversationId }) => {
|
||||
try {
|
||||
return String(await start({ ...args, memory_id: conversationId }))
|
||||
} catch (e) {
|
||||
// The host relays the server's refusal as the error's text, possibly behind a stack.
|
||||
const text = e instanceof Error ? e.message : String(e)
|
||||
throw turnRunningError(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)) ?? e
|
||||
}
|
||||
},
|
||||
follow: (jobId, onStream) =>
|
||||
streamJob(jobId, (update) => {
|
||||
if (update.new_result_stream) onStream(update.new_result_stream)
|
||||
})
|
||||
}
|
||||
}
|
||||
+35
-2
@@ -88,9 +88,33 @@ export type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
||||
|
||||
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
||||
/** A raw app's generated `wmill.ts`, as far as a chat uses it. */
|
||||
export interface RawAppWmill {
|
||||
backendAsync: Record<string, (args: any) => Promise<string>>
|
||||
streamJob: (jobId: string, onUpdate?: (update: { new_result_stream?: string }) => void) => Promise<unknown>
|
||||
}
|
||||
|
||||
/** `ChatOptions` whose `runnable` must name one of `wmill`'s runnables. */
|
||||
export type TypedChatOptions<W extends RawAppWmill> = ChatOptions & {
|
||||
wmill?: W
|
||||
runnable?: keyof W['backendAsync'] & string
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
/** Path of a deployed flow with chat mode enabled, e.g. `f/support/assistant`. */
|
||||
flowPath: string
|
||||
/**
|
||||
* Path of a deployed flow with chat mode enabled, e.g. `f/support/assistant`. Required
|
||||
* unless `runnable` or `follow` is set, where it only names the local history.
|
||||
*/
|
||||
flowPath?: string
|
||||
/**
|
||||
* In a raw app, chat through its agent runnable of this name instead of a flow:
|
||||
* `{ wmill, runnable: 'chat' }`. Each turn runs as the app, so a sandboxed app needs no
|
||||
* frontend SDK scope and its viewers no access to the agent; the chat follows it as
|
||||
* with `follow`. It also names the local history, so each runnable keeps its own.
|
||||
*/
|
||||
runnable?: string
|
||||
/** The app's generated module, `import * as wmill from './wmill'`, which `runnable` goes through. */
|
||||
wmill?: RawAppWmill
|
||||
/** Windmill origin, e.g. `https://app.windmill.dev`. Detected inside a raw app. */
|
||||
baseUrl?: string
|
||||
/** Detected inside a raw app. */
|
||||
@@ -128,6 +152,15 @@ export interface ChatOptions {
|
||||
* Windmill's own editor uses this to chat with an undeployed flow through a preview run.
|
||||
*/
|
||||
run?: (args: Record<string, unknown>, turn: { conversationId: string; signal: AbortSignal }) => Promise<string>
|
||||
/**
|
||||
* Follows the job `run` returned through your own client instead of the SDK's requests:
|
||||
* call `onStream` with each chunk of the job's result stream and resolve with its result.
|
||||
* With it the chat makes no request of its own, so it needs no credential, `baseUrl` or
|
||||
* `workspace`, and in exchange history is `local` (or `none`), `stop()` stops following
|
||||
* without cancelling the job, and attachments are refused. `runnable` sets both for a raw
|
||||
* app's agent runnable.
|
||||
*/
|
||||
follow?: (jobId: string, onStream: (chunk: string) => void, turn: { signal: AbortSignal }) => Promise<unknown>
|
||||
/** Called once a turn has its answer (a failed flow included: its error is the answer). */
|
||||
onFinish?: (turn: { conversationId: string; jobId?: string; messages: ChatMessage[] }) => void
|
||||
/** Called when a turn could not run or be followed; `state.error` holds the same error. */
|
||||
|
||||
@@ -1829,6 +1829,7 @@ describe('createChat with server history', () => {
|
||||
await chat.selectConversation(older)
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
const again = createChat(options({ token: 'tok', storage }, fetch))
|
||||
expect(again.getState().conversations.map((c) => c.id)).toEqual([newer, older])
|
||||
expect((await again.loadConversations()).map((c) => c.id)).toEqual([newer, older])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { TurnRunningError } from '../src/api'
|
||||
import { createChat } from '../src/chat'
|
||||
import { memoryStorage, ndjson } from './support'
|
||||
|
||||
describe('raw app agent runnable', () => {
|
||||
test('chats through the runnable without a request of its own', async () => {
|
||||
const calls: unknown[] = []
|
||||
let refuse = false
|
||||
const start = async (args: unknown) => {
|
||||
// As the host relays a 409: the server's body behind the error's stack.
|
||||
if (refuse) {
|
||||
const body = { error: 'still answering', running_turn: { job_id: 'job-1', user_seq: 1 } }
|
||||
throw new Error(`Error: ${JSON.stringify(body)}\n at x`)
|
||||
}
|
||||
calls.push(args)
|
||||
return `job-${calls.length}`
|
||||
}
|
||||
const streamJob = async (jobId: string, onUpdate?: (u: { new_result_stream?: string }) => void) => {
|
||||
const stream = ndjson({ type: 'token_delta', content: 'answer ' }, { type: 'token_delta', content: jobId })
|
||||
onUpdate?.({ new_result_stream: stream.slice(0, 10) })
|
||||
onUpdate?.({ new_result_stream: stream.slice(10) })
|
||||
if (jobId === 'job-3') return { error: { name: 'Error', message: 'agent failed' } }
|
||||
return { output: `answer ${jobId}`, messages: [] }
|
||||
}
|
||||
const storage = memoryStorage()
|
||||
const wmill = { backendAsync: { chat: start, other: start }, streamJob }
|
||||
const chat = createChat({
|
||||
wmill,
|
||||
runnable: 'chat',
|
||||
storage,
|
||||
fetch: () => Promise.reject(new Error('no request expected'))
|
||||
})
|
||||
expect(chat.getState().history).toBe('local')
|
||||
await chat.sendMessage('hi')
|
||||
await chat.sendMessage('again')
|
||||
const conversationId = chat.getState().conversationId
|
||||
expect(calls).toEqual([
|
||||
{ user_message: 'hi', memory_id: conversationId },
|
||||
{ user_message: 'again', memory_id: conversationId }
|
||||
])
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'answer job-1', 'again', 'answer job-2'])
|
||||
|
||||
await chat.sendMessage('third')
|
||||
const messages = chat.getState().messages
|
||||
const last = messages[messages.length - 1]
|
||||
expect([last.content, last.success]).toEqual(['agent failed', false])
|
||||
|
||||
// A turn still running elsewhere takes the message back rather than failing a turn.
|
||||
refuse = true
|
||||
await expect(chat.sendMessage('fourth')).rejects.toBeInstanceOf(TurnRunningError)
|
||||
expect(chat.getState().messages).toEqual(messages)
|
||||
|
||||
// Each runnable keeps its own history: another agent never sees this conversation.
|
||||
expect(createChat({ wmill, runnable: 'chat', storage }).getState().conversations).toHaveLength(1)
|
||||
expect(createChat({ wmill, runnable: 'other', storage }).getState().conversations).toEqual([])
|
||||
// Nor does the same runnable name in another app, whose path the host puts in `ctx`.
|
||||
const g = globalThis as { ctx?: unknown }
|
||||
g.ctx = { path: 'u/admin/other_app' }
|
||||
try {
|
||||
expect(createChat({ wmill, runnable: 'chat', storage }).getState().conversations).toEqual([])
|
||||
} finally {
|
||||
delete g.ctx
|
||||
}
|
||||
// @ts-expect-error a name the app's module does not declare
|
||||
createChat({ wmill, runnable: 'missing', storage })
|
||||
})
|
||||
})
|
||||
@@ -1711,6 +1711,49 @@ export function buildWmillTs(
|
||||
return windmillUtils.genWmillTs(runnables);
|
||||
}
|
||||
|
||||
/**
|
||||
* The inputs of an agent runnable, after the agent's memory (see the frontend's
|
||||
* `agentRunnableSchema`): `memory_id` when it keeps managed memory, `previous_messages` when it
|
||||
* is off, and neither for an older `manual` setting.
|
||||
*/
|
||||
function agentRunnableSchema(memory: any) {
|
||||
const managed =
|
||||
memory?.kind === "compaction" ||
|
||||
((memory?.kind === "window" || memory?.kind === "auto") &&
|
||||
Boolean(memory.context_length));
|
||||
const legacy = memory?.kind === "manual";
|
||||
const history: Record<string, any> = {};
|
||||
if (managed) {
|
||||
history.memory_id = {
|
||||
type: "string",
|
||||
description:
|
||||
"The conversation the message belongs to: reuse it to continue one, or omit it for a single run",
|
||||
};
|
||||
} else if (!legacy) {
|
||||
history.previous_messages = {
|
||||
type: "array",
|
||||
description:
|
||||
"The history to answer from, as chat messages: this agent keeps no memory",
|
||||
items: { type: "object" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
properties: {
|
||||
user_message: { type: "string", description: "The message to answer" },
|
||||
...history,
|
||||
user_attachments: {
|
||||
type: "array",
|
||||
description: "Files the message carries, as object storage references",
|
||||
items: { type: "object", resourceType: "s3object" },
|
||||
},
|
||||
},
|
||||
required: ["user_message"],
|
||||
order: ["user_message", ...Object.keys(history), "user_attachments"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches schemas from the Windmill API for path-based runnables (script / flow).
|
||||
* Returns a map of runnableId -> schema. Path-based runnables don't store their
|
||||
@@ -1741,6 +1784,12 @@ export async function fetchPathRunnableSchemas(
|
||||
path: runnable.path,
|
||||
});
|
||||
if (script.schema) schemas[runnableId] = script.schema;
|
||||
} else if (runnable.runType === "agent") {
|
||||
const agent = await wmill.getResource({
|
||||
workspace: workspaceId,
|
||||
path: runnable.path,
|
||||
});
|
||||
schemas[runnableId] = agentRunnableSchema((agent.value as any)?.memory);
|
||||
} else if (runnable.runType === "flow") {
|
||||
const flow = await wmill.getFlowByPath({
|
||||
workspace: workspaceId,
|
||||
@@ -1771,7 +1820,8 @@ function convertRunnablesToApiFormat(runnables: Record<string, any>): void {
|
||||
if (
|
||||
runnable?.type === "script" ||
|
||||
runnable?.type === "hubscript" ||
|
||||
runnable?.type === "flow"
|
||||
runnable?.type === "flow" ||
|
||||
runnable?.type === "agent"
|
||||
) {
|
||||
// Convert from file format to API format
|
||||
// { type: "script" } -> { type: "path", runType: "script" }
|
||||
@@ -2029,9 +2079,11 @@ async function streamJobWithSSE(
|
||||
ws: WebSocket,
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
streamOffset?: number,
|
||||
): Promise<void> {
|
||||
const sseUrl =
|
||||
`${baseUrl}api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?fast=true`;
|
||||
`${baseUrl}api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?fast=true` +
|
||||
(streamOffset !== undefined ? `&stream_offset=${streamOffset}` : "");
|
||||
|
||||
const extraHeaders = getHeaders();
|
||||
const response = await fetch(sseUrl, {
|
||||
@@ -2057,6 +2109,7 @@ async function streamJobWithSSE(
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let lastOffset = streamOffset;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -2076,8 +2129,17 @@ async function streamJobWithSSE(
|
||||
|
||||
if (type === "ping" || type === "timeout") {
|
||||
if (type === "timeout") {
|
||||
// The server ends every stream after a while; the job goes on, so it is resumed.
|
||||
reader.cancel();
|
||||
return;
|
||||
return streamJobWithSSE(
|
||||
workspace,
|
||||
jobId,
|
||||
reqId,
|
||||
ws,
|
||||
baseUrl,
|
||||
token,
|
||||
lastOffset,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -2108,6 +2170,9 @@ async function streamJobWithSSE(
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.stream_offset !== undefined) {
|
||||
lastOffset = update.stream_offset;
|
||||
}
|
||||
// Send stream update if there's new stream data
|
||||
if (update.new_result_stream !== undefined) {
|
||||
ws.send(
|
||||
|
||||
@@ -14,7 +14,7 @@ export type Runnable =
|
||||
}
|
||||
| {
|
||||
type: "path" | "runnableByPath";
|
||||
runType: "script" | "hubscript" | "flow";
|
||||
runType: "script" | "hubscript" | "flow" | "agent";
|
||||
path: string;
|
||||
fields?: Record<string, any>;
|
||||
};
|
||||
|
||||
@@ -230,7 +230,8 @@ export async function loadRunnablesFromBackend(
|
||||
} else if (
|
||||
runnable?.type === "script" ||
|
||||
runnable?.type === "hubscript" ||
|
||||
runnable?.type === "flow"
|
||||
runnable?.type === "flow" ||
|
||||
runnable?.type === "agent"
|
||||
) {
|
||||
// For path-based runnables, convert from file format to API format
|
||||
// { type: "script" } -> { type: "path", runType: "script" }
|
||||
|
||||
Generated
+29
-6
@@ -6839,7 +6839,7 @@ An app can be demoed by recording a session: every interaction becomes a step ca
|
||||
|
||||
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
|
||||
|
||||
### Chat UIs over a flow in chat mode
|
||||
### Chat UIs over a flow in chat mode or a saved agent
|
||||
|
||||
A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`@windmill-labs/chat-sdk\` to \`package.json\` and use it directly, it detects the app's Windmill and credential.
|
||||
|
||||
@@ -6852,9 +6852,31 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' })
|
||||
|
||||
\`@windmill-labs/chat-sdk/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`@windmill-labs/chat-sdk/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser.
|
||||
|
||||
#### A saved agent: an \`agent\` runnable
|
||||
|
||||
Chat with a saved AI agent (an \`ai_agent\` resource) through an \`agent\` runnable, a path runnable whose \`path\` is the agent's. Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no frontend SDK scope, viewers get no consent prompt and need no access to the agent. Prefer it in every app, sandboxed or not:
|
||||
|
||||
In \`raw_app.yaml\`:
|
||||
|
||||
\`\`\`yaml
|
||||
runnables:
|
||||
chat:
|
||||
type: agent
|
||||
path: f/support/agent
|
||||
\`\`\`
|
||||
|
||||
\`\`\`tsx
|
||||
import { useWindmillChat } from '@windmill-labs/chat-sdk/react'
|
||||
import * as wmill from './wmill'
|
||||
|
||||
const chat = useWindmillChat({ wmill, runnable: 'chat' })
|
||||
\`\`\`
|
||||
|
||||
\`runnable\` is the runnable's key; each runnable keeps its own conversations. The runnable takes \`user_message\` and \`memory_id\`, the conversation id, which the chat fills in: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as \`backend.<key>({ user_message })\` without \`memory_id\`, it is a single run with no conversation, for a one-off question. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes \`previous_messages\` (chat messages) instead of \`memory_id\`, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. \`chat.stop()\` stops following without cancelling the turn, and a message sent while it still runs is refused with \`TurnRunningError\`. Attachments are not supported this way.
|
||||
|
||||
## Backend runnables
|
||||
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of four types:
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of five types:
|
||||
|
||||
| Type | What it is |
|
||||
|---|---|
|
||||
@@ -6862,6 +6884,7 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo
|
||||
| \`script\` | Reference to an existing workspace script by path. |
|
||||
| \`flow\` | Reference to an existing workspace flow by path. |
|
||||
| \`hubscript\` | Reference to a hub script by path. |
|
||||
| \`agent\` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). |
|
||||
|
||||
### Inline runnables
|
||||
|
||||
@@ -6903,9 +6926,9 @@ An inline runnable runs as an ordinary Windmill job. \`import * as wmill from 'w
|
||||
|
||||
Use only \`wmill\` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (\`JobService\`, \`ScriptService\`, ...) are importable from \`windmill-client\`. What is not available is a name you guessed at: \`getBaseUrl\` and \`getWorkspaceToken\` are inventions, not API.
|
||||
|
||||
### Path runnables (script / flow / hubscript)
|
||||
### Path runnables (script / flow / hubscript / agent)
|
||||
|
||||
When \`type\` is \`script\`, \`flow\`, or \`hubscript\`, the runnable just stores a \`path\` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface.
|
||||
When \`type\` is \`script\`, \`flow\`, \`hubscript\` or \`agent\`, the runnable just stores a \`path\` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is \`user_message\` and \`user_attachments\`, plus \`memory_id\` when the agent keeps managed memory or \`previous_messages\` when its memory is off.
|
||||
|
||||
Before writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at \`hub/<version>/<app>/<name>\`) for a third-party service, and reference it instead of copying its logic.
|
||||
|
||||
@@ -6914,7 +6937,7 @@ Before writing an inline runnable, look for a workspace script or flow that alre
|
||||
This decides whether an app works before anything is deployed:
|
||||
|
||||
- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.
|
||||
- **Path runnables (\`script\` / \`flow\` / \`hubscript\`) run the DEPLOYED item at that path.** So do \`wmill.runFlow\`, \`wmill.runFlowAsync\` and \`wmill.runScriptByPath\` called from inside a runnable. A draft — including a draft you just created — does not exist for them.
|
||||
- **Path runnables (\`script\` / \`flow\` / \`hubscript\` / \`agent\`) run the DEPLOYED item at that path.** So do \`wmill.runFlow\`, \`wmill.runFlowAsync\` and \`wmill.runScriptByPath\` called from inside a runnable. A draft — including a draft you just created — does not exist for them.
|
||||
|
||||
So an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.
|
||||
|
||||
@@ -7024,7 +7047,7 @@ def main(user_id: str):
|
||||
7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable.
|
||||
9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.
|
||||
10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.
|
||||
10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow or a saved agent** — never hand-roll a runnable that runs it and polls its stream; a saved agent is an \`agent\` runnable passed as \`{ wmill, runnable }\`.
|
||||
`,
|
||||
"triggers": `---
|
||||
name: triggers
|
||||
|
||||
@@ -66,7 +66,8 @@ resolves the runnable it runs on the root handle, so `validate_operator_composed
|
||||
referenced path under the caller's RLS and refuses hub ones. For a script it checks the version a
|
||||
run would pick: grants are per version, and a path reused after its versions were archived starts
|
||||
with none while the archived ones keep theirs. But it has to check **two** surfaces,
|
||||
because they are not the same list: the policy's `script/<path>` and `flow/<path>` triggerables,
|
||||
because they are not the same list: the policy's `script/<path>`, `flow/<path>` and
|
||||
`agent/<path>` triggerables (an agent is checked as an `ai_agent` resource the caller can read),
|
||||
and the by-path entries of the app value's `runnables` map, which is what the editor resolves a
|
||||
`runnable_id` against and sends. Read only that map, and refuse an entry whose `type`, `path` or
|
||||
`runType` is not text.
|
||||
@@ -77,8 +78,8 @@ at run time for `component = "a:b"`. Every colon is a possible split, so the che
|
||||
suffix that parses as a runnable rather than guessing which one a request will use.
|
||||
|
||||
What makes those checks bind is that **`ExecutionMode::Viewer` is refused for a builder app**. In
|
||||
Viewer mode `execute_component` falls back to a default triggerable for any `script/`/`flow/`
|
||||
path, so the policy stops being the list of what the app may invoke, and the job runs as the
|
||||
Viewer mode `execute_component` falls back to a default triggerable for any `script/`/`flow/`/
|
||||
`agent/` path, so the policy stops being the list of what the app may invoke, and the job runs as the
|
||||
*viewer*: an admin who merely opened the app would run anything in the workspace as themselves.
|
||||
`Publisher`, `Guest` and `Anonymous` have no such fallback; all three run as the publisher. If you ever relax the Viewer refusal, the
|
||||
deploy-time path checks above stop being an authorization boundary.
|
||||
|
||||
@@ -283,6 +283,8 @@
|
||||
result.push({ kind: 'script', path: runnable.path })
|
||||
} else if (runnable.runType == 'flow') {
|
||||
result.push({ kind: 'flow', path: runnable.path })
|
||||
} else if (runnable.runType == 'agent') {
|
||||
result.push({ kind: 'resource', path: runnable.path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +101,14 @@
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={picker} size="1000px">
|
||||
<DrawerContent title="Script/Flow Picker" on:close={picker.closeDrawer}>
|
||||
<DrawerContent title="Fork a script" on:close={picker.closeDrawer}>
|
||||
<div>
|
||||
<div class="max-w-6xl">
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab value="workspacescripts" label="Workspace Scripts" icon={Building} />
|
||||
<Tab value="workspacescripts" label="Workspace scripts" icon={Building} />
|
||||
|
||||
{#if !composeOnlyApps}
|
||||
<Tab value="hubscripts" label="Hub Scripts" icon={Globe2} />
|
||||
<Tab value="hubscripts" label="Hub scripts" icon={Globe2} />
|
||||
{/if}
|
||||
</Tabs>
|
||||
<div class="my-2"></div>
|
||||
|
||||
+131
-41
@@ -2,7 +2,7 @@
|
||||
import { createBubbler, stopPropagation } from 'svelte/legacy'
|
||||
|
||||
const bubble = createBubbler()
|
||||
import { Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import { Alert, Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import FlowModuleScript from '$lib/components/flows/content/FlowModuleScript.svelte'
|
||||
@@ -32,7 +32,11 @@
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import ScriptEditorDrawer from '$lib/components/flows/content/ScriptEditorDrawer.svelte'
|
||||
import FlowEditorDrawer from '$lib/components/flows/content/FlowEditorDrawer.svelte'
|
||||
import { FlowService, ScriptService, type OpenFlow } from '$lib/gen'
|
||||
import { FlowService, ResourceService, ScriptService, type OpenFlow } from '$lib/gen'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import { summarizeAgentBrain, type AgentTool } from '$lib/components/flows/agentResourceUtils'
|
||||
import { agentRunnableSchema } from '$lib/components/raw_apps/agentRunnable'
|
||||
import { agentMemoryMode } from '$lib/components/flows/agentFormFields'
|
||||
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
@@ -149,6 +153,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
let agentInfo:
|
||||
| {
|
||||
description?: string
|
||||
rows: { label: string; value: string }[]
|
||||
tools: string[]
|
||||
memoryOff: boolean
|
||||
}
|
||||
| undefined = $state()
|
||||
|
||||
async function refreshAgent(runnable: RunnableByPath) {
|
||||
hubFlowPreview = undefined
|
||||
try {
|
||||
const agent = await ResourceService.getResource({
|
||||
workspace: $operatingWorkspace ?? '',
|
||||
path: runnable.path
|
||||
})
|
||||
const config = agent.value as any
|
||||
agentInfo = {
|
||||
description: agent.description ?? undefined,
|
||||
rows: summarizeAgentBrain(config),
|
||||
tools: ((config?.tools ?? []) as AgentTool[]).map(
|
||||
(t) => t.summary || t.value?.tool_type || t.id
|
||||
),
|
||||
memoryOff: agentMemoryMode(config?.memory) === 'off'
|
||||
}
|
||||
const schema = agentRunnableSchema(config?.memory)
|
||||
if (!deepEqual(runnable.schema, schema)) runnable.schema = schema
|
||||
// Every input of a turn comes from its caller: a static one would be pinned by the
|
||||
// deployed policy, answering every viewer's message with the same one.
|
||||
if (
|
||||
Object.values(fields ?? {}).some((f) => f?.type !== 'user') ||
|
||||
!deepEqual(Object.keys(fields ?? {}).sort(), Object.keys(schema.properties).sort())
|
||||
) {
|
||||
fields = computeFields(schema, true, {})
|
||||
}
|
||||
} catch (e) {
|
||||
notFound = true
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function fork(path: string) {
|
||||
let { content, language, schema } = await getScriptByPath(path)
|
||||
if (!schema || typeof schema == 'string' || Object.keys(schema).length == 0) {
|
||||
@@ -205,6 +250,8 @@
|
||||
refreshScript(runnable)
|
||||
} else if (runnable.runType == 'flow') {
|
||||
refreshFlow(runnable)
|
||||
} else if (runnable.runType == 'agent') {
|
||||
refreshAgent(runnable)
|
||||
} else {
|
||||
hubFlowPreview = undefined
|
||||
}
|
||||
@@ -352,6 +399,17 @@
|
||||
Details
|
||||
</Button>
|
||||
{/if}
|
||||
{:else if runnable.runType == 'agent'}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Pen }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
target="_blank"
|
||||
href="/agents/edit/{runnable.path}?workspace={$operatingWorkspace}"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -376,45 +434,47 @@
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
<Popover
|
||||
floatingConfig={{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: [
|
||||
'bottom-start',
|
||||
'bottom-end',
|
||||
'top-start',
|
||||
'top-end',
|
||||
'top',
|
||||
'bottom'
|
||||
]
|
||||
})
|
||||
]
|
||||
}}
|
||||
closeButton
|
||||
contentClasses="block text-primary text-xs p-4 w-[20vh]"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
nonCaptureEvent={true}
|
||||
btnClasses={'bg-surface text-primay hover:bg-hover'}
|
||||
variant="default"
|
||||
size="xs"
|
||||
>
|
||||
Cache
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
{#if runnable.runType == 'flow' && isHubFlowPath(runnable.path)}
|
||||
Since this is a reference to a hub flow, cache settings are managed from the flow after
|
||||
you fork it into your workspace.
|
||||
{:else}
|
||||
Since this is a reference to a workspace {runnable.runType}, set the cache in the
|
||||
{runnable.runType} settings directly by editing it. The cache will be shared by any app or
|
||||
flow that uses this {runnable.runType}.
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{#if runnable.runType != 'agent'}
|
||||
<Popover
|
||||
floatingConfig={{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: [
|
||||
'bottom-start',
|
||||
'bottom-end',
|
||||
'top-start',
|
||||
'top-end',
|
||||
'top',
|
||||
'bottom'
|
||||
]
|
||||
})
|
||||
]
|
||||
}}
|
||||
closeButton
|
||||
contentClasses="block text-primary text-xs p-4 w-[20vh]"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
nonCaptureEvent={true}
|
||||
btnClasses={'bg-surface text-primay hover:bg-hover'}
|
||||
variant="default"
|
||||
size="xs"
|
||||
>
|
||||
Cache
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
{#if runnable.runType == 'flow' && isHubFlowPath(runnable.path)}
|
||||
Since this is a reference to a hub flow, cache settings are managed from the flow after
|
||||
you fork it into your workspace.
|
||||
{:else}
|
||||
Since this is a reference to a workspace {runnable.runType}, set the cache in the
|
||||
{runnable.runType} settings directly by editing it. The cache will be shared by any app or
|
||||
flow that uses this {runnable.runType}.
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
onkeydown={stopPropagation(bubble('keydown'))}
|
||||
@@ -454,6 +514,36 @@
|
||||
{:else}
|
||||
<FlowPathViewer path={runnable.path} fillAvailableHeight />
|
||||
{/if}
|
||||
{:else if runnable.runType == 'agent'}
|
||||
<div class="flex flex-col gap-2 p-2">
|
||||
{#if agentInfo?.memoryOff}
|
||||
<Alert type="info" size="xs" title="Managed memory is off">
|
||||
Each call is answered on its own inputs, so a chat would forget every turn. Pass
|
||||
<code>previous_messages</code> to give it the history.
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if agentInfo?.description}
|
||||
<p class="text-xs text-secondary">{agentInfo.description}</p>
|
||||
{/if}
|
||||
<dl class="flex flex-col gap-1">
|
||||
{#each agentInfo?.rows ?? [] as row (row.label)}
|
||||
<div class="flex items-baseline gap-2 text-2xs">
|
||||
<dt class="text-tertiary shrink-0 w-28">{row.label}</dt>
|
||||
<dd class="text-secondary truncate" title={row.value}>{row.value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
{#if agentInfo?.tools.length}
|
||||
<div class="flex items-baseline gap-2 text-2xs">
|
||||
<dt class="text-tertiary shrink-0 w-28">Tools</dt>
|
||||
<dd class="flex flex-wrap gap-1">
|
||||
{#each agentInfo.tools as tool, i (i)}
|
||||
<Badge color="gray">{tool}</Badge>
|
||||
{/each}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
{:else}
|
||||
Unrecognized runType {runnable.runType}
|
||||
{/if}
|
||||
|
||||
+49
-11
@@ -2,11 +2,17 @@
|
||||
import { Button, Drawer, DrawerContent, Tab, Tabs } from '$lib/components/common'
|
||||
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
|
||||
import PickHubFlow from '$lib/components/flows/pickers/PickHubFlow.svelte'
|
||||
import { Building, Globe2, MousePointer, Plus } from 'lucide-svelte'
|
||||
import { Bot, Building, Globe2, MousePointer, Plus } from 'lucide-svelte'
|
||||
import InlineScriptList from './InlineScriptList.svelte'
|
||||
import type { InlineScript, Runnable, StaticAppInput } from '$lib/components/apps/inputType'
|
||||
import type {
|
||||
InlineScript,
|
||||
PathRunType,
|
||||
Runnable,
|
||||
StaticAppInput
|
||||
} from '$lib/components/apps/inputType'
|
||||
import WorkspaceScriptList from './WorkspaceScriptList.svelte'
|
||||
import WorkspaceFlowList from './WorkspaceFlowList.svelte'
|
||||
import WorkspaceAgentList from './WorkspaceAgentList.svelte'
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
import type { Schema } from '$lib/common'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
@@ -18,7 +24,13 @@
|
||||
const roleCaps = useRoleCapabilities()
|
||||
import { buildPathRunnableSelection } from './runnableSelectorUtils'
|
||||
|
||||
type TabType = 'hubscripts' | 'hubflows' | 'workspacescripts' | 'workspaceflows' | 'inlinescripts'
|
||||
type TabType =
|
||||
| 'hubscripts'
|
||||
| 'hubflows'
|
||||
| 'workspacescripts'
|
||||
| 'workspaceflows'
|
||||
| 'workspaceagents'
|
||||
| 'inlinescripts'
|
||||
|
||||
interface Props {
|
||||
defaultUserInput?: boolean
|
||||
@@ -47,6 +59,13 @@
|
||||
)
|
||||
let filter: string = $state('')
|
||||
let picker: Drawer | undefined = $state()
|
||||
const selectLabel = $derived(
|
||||
onlyFlow
|
||||
? 'Select a flow'
|
||||
: rawApps
|
||||
? 'Select a script, flow or agent'
|
||||
: 'Select a script or flow'
|
||||
)
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
pick: {
|
||||
@@ -57,7 +76,7 @@
|
||||
|
||||
async function loadSchemaFromTriggerable(
|
||||
path: string,
|
||||
runType: 'script' | 'flow' | 'hubscript'
|
||||
runType: PathRunType
|
||||
): Promise<{ schema: Schema; summary: string | undefined }> {
|
||||
const schema = await loadSchema($operatingWorkspace!, path, runType)
|
||||
if (!schema.schema.order) {
|
||||
@@ -94,6 +113,20 @@
|
||||
})
|
||||
}
|
||||
|
||||
async function pickAgent(path: string) {
|
||||
const selection = buildPathRunnableSelection(
|
||||
path,
|
||||
'agent',
|
||||
await loadSchemaFromTriggerable(path, 'agent'),
|
||||
defaultUserInput,
|
||||
rawApps
|
||||
)
|
||||
dispatch('pick', {
|
||||
runnable: selection.runnable,
|
||||
fields: selection.fields
|
||||
})
|
||||
}
|
||||
|
||||
async function pickHubScript(path: string) {
|
||||
const selection = buildPathRunnableSelection(
|
||||
path,
|
||||
@@ -154,22 +187,25 @@
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={picker} size="1000px">
|
||||
<DrawerContent title="Script/Flow Picker" on:close={picker.closeDrawer}>
|
||||
<DrawerContent title={selectLabel} on:close={picker.closeDrawer}>
|
||||
<div>
|
||||
<div class="max-w-6xl">
|
||||
<Tabs bind:selected={tab}>
|
||||
{#if !onlyFlow}
|
||||
{#if !rawApps}
|
||||
<Tab value="inlinescripts" label="Detached Inline Scripts" icon={Building} />
|
||||
<Tab value="inlinescripts" label="Detached inline scripts" icon={Building} />
|
||||
{/if}
|
||||
<Tab value="workspacescripts" label="Workspace Scripts" icon={Building} />
|
||||
<Tab value="workspacescripts" label="Workspace scripts" icon={Building} />
|
||||
{/if}
|
||||
<Tab value="workspaceflows" label="Workspace flows" icon={Building} />
|
||||
{#if rawApps && !onlyFlow}
|
||||
<Tab value="workspaceagents" label="AI agents" icon={Bot} />
|
||||
{/if}
|
||||
<Tab value="workspaceflows" label="Workspace Flows" icon={Building} />
|
||||
{#if $roleCaps.apps !== 'compose'}
|
||||
{#if !onlyFlow}
|
||||
<Tab value="hubscripts" label="Hub Scripts" icon={Globe2} />
|
||||
<Tab value="hubscripts" label="Hub scripts" icon={Globe2} />
|
||||
{/if}
|
||||
<Tab value="hubflows" label="Hub Flows" icon={Globe2} />
|
||||
<Tab value="hubflows" label="Hub flows" icon={Globe2} />
|
||||
{/if}
|
||||
</Tabs>
|
||||
<div class="my-2"></div>
|
||||
@@ -186,6 +222,8 @@
|
||||
<WorkspaceScriptList on:pick={(e) => pickScript(e.detail)} />
|
||||
{:else if tab == 'workspaceflows'}
|
||||
<WorkspaceFlowList on:pick={(e) => pickFlow(e.detail)} />
|
||||
{:else if tab == 'workspaceagents'}
|
||||
<WorkspaceAgentList on:pick={(e) => pickAgent(e.detail)} />
|
||||
{:else if tab == 'hubscripts'}
|
||||
<PickHubScript bind:filter on:pick={(e) => pickHubScript(e.detail.path)} />
|
||||
{:else if tab == 'hubflows'}
|
||||
@@ -218,6 +256,6 @@
|
||||
startIcon={{ icon: MousePointer }}
|
||||
btnClasses="truncate w-full"
|
||||
>
|
||||
{#if onlyFlow}Select a flow{:else}Select a script or flow{/if}
|
||||
{selectLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import { ResourceService, type ListableResource } from '$lib/gen'
|
||||
import { emptyString } from '$lib/utils'
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let filter = $state('')
|
||||
let agents: ListableResource[] | undefined = $state(undefined)
|
||||
let filteredItems: (ListableResource & { marked?: string })[] = $state([])
|
||||
let prefilteredItems = $derived(agents ?? [])
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
onMount(async () => {
|
||||
agents = await ResourceService.listResource({
|
||||
workspace: $operatingWorkspace!,
|
||||
resourceType: 'ai_agent',
|
||||
perPage: 300
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={prefilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (emptyString(x.description) ? x.path : x.description + ' (' + x.path + ')')}
|
||||
/>
|
||||
<div class="w-full mt-1 mb-4">
|
||||
<TextInput
|
||||
bind:value={filter}
|
||||
inputProps={{
|
||||
placeholder: 'Search workspace AI agents',
|
||||
// The picker's drawer must not take the keys typed into its search.
|
||||
onkeydown: (e) => e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if agents}
|
||||
{#if filteredItems.length == 0}
|
||||
<NoItemFound />
|
||||
{:else}
|
||||
<ul class="divide-y border rounded-md">
|
||||
{#each filteredItems as item (item)}
|
||||
<li class="flex flex-row w-full">
|
||||
<button
|
||||
class="p-4 gap-4 flex flex-row grow justify-between hover:bg-surface-hover bg-surface transition-all items-center rounded-md"
|
||||
onclick={() => dispatch('pick', item.path)}
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<RowIcon kind="agent" />
|
||||
|
||||
<div class="w-full text-left font-normal">
|
||||
<div class="text-primary flex-wrap text-md font-semibold mb-1">
|
||||
{#if item.marked}
|
||||
{@html item.marked ?? ''}
|
||||
{:else}
|
||||
{emptyString(item.description) ? item.path : item.description}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-primary text-xs">
|
||||
{item.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each Array(10).fill(0) as _}
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
{/if}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { Schema } from '$lib/common'
|
||||
import type { Runnable, StaticAppInput } from '$lib/components/apps/inputType'
|
||||
import type { PathRunType, Runnable, StaticAppInput } from '$lib/components/apps/inputType'
|
||||
import { schemaToInputsSpec } from '$lib/components/apps/utils'
|
||||
import { defaultIfEmptyString } from '$lib/utils'
|
||||
|
||||
@@ -10,7 +10,7 @@ export type LoadedRunnableSchema = {
|
||||
|
||||
export function buildPathRunnableSelection(
|
||||
path: string,
|
||||
runType: 'script' | 'flow' | 'hubscript',
|
||||
runType: PathRunType,
|
||||
loadedSchema: LoadedRunnableSchema,
|
||||
defaultUserInput: boolean,
|
||||
rawApps: boolean
|
||||
|
||||
@@ -136,11 +136,14 @@ export type TemplateV2Input = {
|
||||
connections: InputConnectionEval[]
|
||||
}
|
||||
|
||||
/** `agent` (a saved AI agent run as one chat turn) exists in raw apps only. */
|
||||
export type PathRunType = 'script' | 'flow' | 'hubscript' | 'agent'
|
||||
|
||||
export type RunnableByPath = {
|
||||
name: string
|
||||
path: string
|
||||
schema: any
|
||||
runType: 'script' | 'flow' | 'hubscript'
|
||||
runType: PathRunType
|
||||
type: 'runnableByPath' | 'path'
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { appDatatableRole, sdkDatatableCall } from '$lib/components/raw_apps/dat
|
||||
import { RAW_APP_BASE } from '$system_prompts'
|
||||
|
||||
// Backend runnable types
|
||||
export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'inline'
|
||||
export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'agent' | 'inline'
|
||||
|
||||
export interface InlineScript {
|
||||
language: 'bun' | 'python3'
|
||||
@@ -311,9 +311,9 @@ const getSetBackendRunnableSchema = memo(() =>
|
||||
),
|
||||
name: z.string().describe('A short summary/description of what the runnable does'),
|
||||
type: z
|
||||
.enum(['script', 'flow', 'hubscript', 'inline'])
|
||||
.enum(['script', 'flow', 'hubscript', 'agent', 'inline'])
|
||||
.describe(
|
||||
'The type of runnable: "inline" for custom code, "script" for workspace script, "flow" for workspace flow, "hubscript" for hub script'
|
||||
'The type of runnable: "inline" for custom code, "script" for workspace script, "flow" for workspace flow, "hubscript" for hub script, "agent" for a saved AI agent (one chat turn)'
|
||||
),
|
||||
staticInputs: z
|
||||
.record(z.string(), z.any())
|
||||
@@ -328,7 +328,7 @@ const getSetBackendRunnableSchema = memo(() =>
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Required when type is "script", "flow", or "hubscript". The fully qualified path to the workspace script/flow or hub script.'
|
||||
'Required when type is "script", "flow", "hubscript" or "agent". The fully qualified path to the workspace script/flow, hub script or AI agent.'
|
||||
),
|
||||
viewerDataAccess: z
|
||||
.boolean()
|
||||
@@ -723,6 +723,11 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
|
||||
}
|
||||
|
||||
const parsedArgs = getSetBackendRunnableSchema().parse(cleanedArgs)
|
||||
if (parsedArgs.type === 'agent' && Object.keys(parsedArgs.staticInputs ?? {}).length > 0) {
|
||||
const errorMsg = `An "agent" runnable takes no staticInputs: each turn's inputs come from the app.`
|
||||
toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg })
|
||||
return errorMsg
|
||||
}
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Setting backend runnable '${parsedArgs.key}'...`
|
||||
})
|
||||
@@ -1087,6 +1092,16 @@ When creating a backend runnable with \`set_backend_runnable\`:
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
5. **For saved AI agents** (type: "agent"), one chat turn per call, taking \`user_message\` and either \`memory_id\` (the conversation id, for an agent with managed memory) or \`previous_messages\` (the history, for one whose memory is off); no staticInputs:
|
||||
\`\`\`
|
||||
{
|
||||
key: "chat",
|
||||
name: "Chat with the support agent",
|
||||
type: "agent",
|
||||
path: "f/support/agent"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Use the smallest context needed. If the target file or runnable is clear, inspect only that item with \`get_frontend_file(path)\` or \`get_backend_runnable(key)\`.
|
||||
|
||||
@@ -2548,6 +2548,38 @@ describe('global AI tools', () => {
|
||||
expect(asPath.inlineScript).toBeUndefined()
|
||||
})
|
||||
|
||||
// A static input is pinned by the deployed policy: carried into an agent runnable, it would
|
||||
// replace every viewer's message.
|
||||
it('drops inherited static inputs when a runnable becomes an agent', async () => {
|
||||
seedBackendDraft(
|
||||
'raw_app',
|
||||
'u/admin/agent_conv_app',
|
||||
{ summary: 'Converted', files: {}, runnables: {}, data: { tables: [] } },
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
await callGlobalTool('write_app_runnable', {
|
||||
path: 'u/admin/agent_conv_app',
|
||||
key: 'chat',
|
||||
runnable: {
|
||||
name: 'Run the flow',
|
||||
type: 'flow',
|
||||
path: 'u/admin/hello_flow',
|
||||
staticInputs: { user_message: 'pinned' }
|
||||
}
|
||||
})
|
||||
await callGlobalTool('write_app_runnable', {
|
||||
path: 'u/admin/agent_conv_app',
|
||||
key: 'chat',
|
||||
runnable: { name: 'Chat', type: 'agent', path: 'u/admin/assistant' }
|
||||
})
|
||||
|
||||
const asAgent = getBackendDraft<any>('raw_app', 'u/admin/agent_conv_app', {
|
||||
workspace: WORKSPACE
|
||||
}).runnables.chat
|
||||
expect(asAgent.runType).toBe('agent')
|
||||
expect(asAgent.fields).toEqual({})
|
||||
})
|
||||
|
||||
it("resets a path runnable's schema when it is retargeted, and keeps it when it is not", async () => {
|
||||
// The editor populates `schema` from the item the runnable points at, and
|
||||
// genWmillTs types `backend.<key>(args)` from it — so it must not outlive the target.
|
||||
|
||||
@@ -180,7 +180,7 @@ import { fileTools } from '../files/fileTools'
|
||||
import type { AttachedFilesStore } from '../files/attachedFiles.svelte'
|
||||
import { artifactTools } from '../artifacts/artifactTools'
|
||||
import type { SessionArtifactsStore } from '../artifacts/artifactsState.svelte'
|
||||
import type { Runnable } from '$lib/components/apps/inputType'
|
||||
import type { PathRunType, Runnable } from '$lib/components/apps/inputType'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import type { Schema } from '$lib/common'
|
||||
@@ -1076,9 +1076,9 @@ const backendRunnableSchema = z
|
||||
.object({
|
||||
name: z.string().describe('Short summary/description of what the runnable does.'),
|
||||
type: z
|
||||
.enum(['inline', 'script', 'flow', 'hubscript'])
|
||||
.enum(['inline', 'script', 'flow', 'hubscript', 'agent'])
|
||||
.describe(
|
||||
'Runnable kind: "inline" for custom code stored on the app; "script"/"flow" for a workspace runnable; "hubscript" for a hub script.'
|
||||
'Runnable kind: "inline" for custom code stored on the app; "script"/"flow" for a workspace runnable; "hubscript" for a hub script; "agent" for a saved AI agent, run as one chat turn.'
|
||||
),
|
||||
staticInputs: z
|
||||
.record(z.string(), z.any())
|
||||
@@ -1097,7 +1097,7 @@ const backendRunnableSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Required when type is "script", "flow", or "hubscript". Workspace path of the runnable.'
|
||||
'Required when type is "script", "flow", "hubscript" or "agent". Workspace path of the runnable.'
|
||||
),
|
||||
viewerDataAccess: z
|
||||
.boolean()
|
||||
@@ -2008,7 +2008,7 @@ type BackendRunnableInput = z.infer<typeof backendRunnableSchema>
|
||||
type PersistedRunnable = {
|
||||
name: string
|
||||
type: 'inline' | 'path' | 'runnableByName' | 'runnableByPath'
|
||||
runType?: 'script' | 'flow' | 'hubscript'
|
||||
runType?: PathRunType
|
||||
path?: string
|
||||
inlineScript?: { language: string; content: string }
|
||||
fields?: Record<string, any>
|
||||
@@ -2041,6 +2041,7 @@ function convertPersistedToBackendRunnable(
|
||||
} else if (persisted.type === 'path' || persisted.type === 'runnableByPath') {
|
||||
if (persisted.runType === 'flow') out.type = 'flow'
|
||||
else if (persisted.runType === 'hubscript') out.type = 'hubscript'
|
||||
else if (persisted.runType === 'agent') out.type = 'agent'
|
||||
else out.type = 'script'
|
||||
out.path = persisted.path
|
||||
}
|
||||
@@ -2062,14 +2063,19 @@ function buildPersistedRunnable(
|
||||
input: BackendRunnableInput,
|
||||
existing: PersistedRunnable | undefined
|
||||
): PersistedRunnable {
|
||||
const fields = input.staticInputs
|
||||
? Object.fromEntries(
|
||||
Object.entries(input.staticInputs).map(([k, v]) => [
|
||||
k,
|
||||
{ type: 'static', value: v, fieldType: 'object' }
|
||||
])
|
||||
)
|
||||
: (existing?.fields ?? {})
|
||||
// An agent runnable takes every input from the app: a static one inherited from the kind it was
|
||||
// converted from would be pinned by the deployed policy over every viewer's message.
|
||||
const fields =
|
||||
input.type === 'agent'
|
||||
? {}
|
||||
: input.staticInputs
|
||||
? Object.fromEntries(
|
||||
Object.entries(input.staticInputs).map(([k, v]) => [
|
||||
k,
|
||||
{ type: 'static', value: v, fieldType: 'object' }
|
||||
])
|
||||
)
|
||||
: (existing?.fields ?? {})
|
||||
|
||||
// Converting between kinds must drop the other kind's fields. Every consumer dispatches on
|
||||
// `type` (isRunnableByName / isRunnableByPath), so a leftover does not change which code
|
||||
@@ -2110,7 +2116,15 @@ function buildPersistedRunnable(
|
||||
}
|
||||
|
||||
if (!input.path) {
|
||||
throw new Error('path is required when runnable type is "script", "flow", or "hubscript".')
|
||||
throw new Error(
|
||||
'path is required when runnable type is "script", "flow", "hubscript" or "agent".'
|
||||
)
|
||||
}
|
||||
// A static input is pinned by the deployed policy: every viewer's turn would send the same one.
|
||||
if (input.type === 'agent' && Object.keys(input.staticInputs ?? {}).length > 0) {
|
||||
throw new Error(
|
||||
'An "agent" runnable takes no staticInputs: each turn\'s inputs come from the app.'
|
||||
)
|
||||
}
|
||||
return {
|
||||
...carried,
|
||||
@@ -9425,9 +9439,16 @@ async function undeployedRunnableTargets(
|
||||
? runnable?.runType
|
||||
: runnable?.type
|
||||
const path = runnable?.path
|
||||
if ((type !== 'script' && type !== 'flow') || !path) continue
|
||||
if ((type !== 'script' && type !== 'flow' && type !== 'agent') || !path) continue
|
||||
try {
|
||||
if (await deployedItemExists(workspace, type, path, undefined)) continue
|
||||
if (type === 'agent') {
|
||||
// A saved agent is an `ai_agent` resource; another resource at the path runs nothing.
|
||||
const found = await ResourceService.getResource({ workspace, path }).catch((e) => {
|
||||
if ((e as { status?: number } | null | undefined)?.status === 404) return undefined
|
||||
throw e
|
||||
})
|
||||
if (found?.resource_type === 'ai_agent') continue
|
||||
} else if (await deployedItemExists(workspace, type, path, undefined)) continue
|
||||
} catch {
|
||||
// The probe is advisory: a lookup failure must never block the write or
|
||||
// the deploy it annotates.
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
AGENT_EDITOR_RUN_INPUTS,
|
||||
AGENT_TOOLS_ROW,
|
||||
DEFAULT_AGENT_MEMORY,
|
||||
keepsManagedMemory
|
||||
keepsManagedMemory,
|
||||
agentMemoryMode
|
||||
} from '../agentFormFields'
|
||||
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
|
||||
import { useAgentDraft, type AgentRunAs } from '../agentDraft.svelte'
|
||||
@@ -243,6 +244,23 @@
|
||||
// apart from the echo of their own write.
|
||||
let lastArgs = $state<string | undefined>(undefined)
|
||||
|
||||
// With memory off a run keeps no history of its own, so the form also takes the one to answer
|
||||
// from; managed memory and an older setting would ignore one.
|
||||
let formSchema = $derived(
|
||||
agentMemoryMode((agentValue?.input_transforms?.memory as any)?.value) !== 'off'
|
||||
? AGENT_CHAT_SCHEMA
|
||||
: {
|
||||
...AGENT_CHAT_SCHEMA,
|
||||
properties: {
|
||||
...AGENT_CHAT_SCHEMA.properties,
|
||||
previous_messages: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
description: 'The conversation so far, as chat messages, for the agent to answer from'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
// Picked on the first load, after which it is the reader's: following the memory setting live
|
||||
// would pull them out of a chat for switching memory off in the form.
|
||||
let testMode = $state<'form' | 'chat' | undefined>(undefined)
|
||||
@@ -603,7 +621,7 @@
|
||||
<div class="flex-1 min-h-0 overflow-auto p-4 {testMode === 'chat' ? 'hidden' : ''}">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
{@render viewForm?.({
|
||||
schema: AGENT_CHAT_SCHEMA,
|
||||
schema: formSchema,
|
||||
run: runOnce,
|
||||
loading: runLoading,
|
||||
actions: onOpenConfig ? configurationButton : undefined
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
{@render children?.()}
|
||||
<TextInput
|
||||
inputProps={{
|
||||
placeholder: 'Search Hub Flows'
|
||||
placeholder: 'Search Hub flows'
|
||||
}}
|
||||
bind:value={filter}
|
||||
{size}
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
<div class="relative w-full">
|
||||
<TextInput
|
||||
inputProps={{
|
||||
placeholder: 'Search Hub Scripts'
|
||||
placeholder: 'Search Hub scripts'
|
||||
}}
|
||||
bind:value={filter}
|
||||
class="grow !pr-9"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte'
|
||||
import { executeRunnable } from '../apps/components/helpers/executeRunnable'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { waitJob } from '../waitJob'
|
||||
@@ -54,6 +55,9 @@
|
||||
|
||||
// Job ids launched by this app instance — see `gateJobIds`.
|
||||
const launchedJobs = new Set<string>()
|
||||
// A stream resumed on every server timeout would otherwise outlive the app it answers.
|
||||
let destroyed = false
|
||||
onDestroy(() => (destroyed = true))
|
||||
|
||||
/** For a host that loads this component after the bundle may already have sent requests. */
|
||||
export function handleMessage(event: MessageEvent) {
|
||||
@@ -124,48 +128,64 @@
|
||||
args[key] = `$ctx:${field.ctx}`
|
||||
}
|
||||
}
|
||||
const uuid = await executeRunnable(
|
||||
runnable,
|
||||
workspace,
|
||||
undefined,
|
||||
$userStore?.username,
|
||||
path,
|
||||
runnable_id,
|
||||
{
|
||||
component: runnable_id,
|
||||
args,
|
||||
force_viewer_allow_user_resources: editor
|
||||
? undefinedIfEmpty(
|
||||
Object.keys(runnable?.fields ?? {}).filter(
|
||||
(k) =>
|
||||
runnable?.fields?.[k]?.type == 'user' &&
|
||||
runnable?.fields?.[k]?.allowUserResources
|
||||
let uuid: string
|
||||
try {
|
||||
uuid = await executeRunnable(
|
||||
runnable,
|
||||
workspace,
|
||||
undefined,
|
||||
$userStore?.username,
|
||||
path,
|
||||
runnable_id,
|
||||
{
|
||||
component: runnable_id,
|
||||
args,
|
||||
force_viewer_allow_user_resources: editor
|
||||
? undefinedIfEmpty(
|
||||
Object.keys(runnable?.fields ?? {}).filter(
|
||||
(k) =>
|
||||
runnable?.fields?.[k]?.type == 'user' &&
|
||||
runnable?.fields?.[k]?.allowUserResources
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined,
|
||||
force_viewer_one_of_fields: undefined,
|
||||
force_viewer_static_fields: editor
|
||||
? Object.fromEntries(
|
||||
Object.entries(runnable?.fields ?? {})
|
||||
.filter(([k, v]) => v.type == 'static')
|
||||
.map(([k, v]) => [k, v?.['value']])
|
||||
)
|
||||
: undefined,
|
||||
force_viewer_sensitive_inputs: editor
|
||||
? undefinedIfEmpty(
|
||||
Object.keys(runnable?.fields ?? {}).filter(
|
||||
(k) => runnable?.fields?.[k]?.type == 'user' && runnable?.fields?.[k]?.sensitive
|
||||
: undefined,
|
||||
force_viewer_one_of_fields: undefined,
|
||||
force_viewer_static_fields: editor
|
||||
? Object.fromEntries(
|
||||
Object.entries(runnable?.fields ?? {})
|
||||
.filter(([k, v]) => v.type == 'static')
|
||||
.map(([k, v]) => [k, v?.['value']])
|
||||
)
|
||||
)
|
||||
: undefined,
|
||||
force_viewer_delete_after_secs: editor ? runnable?.delete_after_secs : undefined,
|
||||
on_behalf_of: editor ? previewOnBehalfOf : undefined,
|
||||
viewer_data_access: editor ? runnable?.viewer_data_access || undefined : undefined
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
editor ? rawAppPreviewModules(runnables, runnable_id) : undefined
|
||||
)
|
||||
: undefined,
|
||||
force_viewer_sensitive_inputs: editor
|
||||
? undefinedIfEmpty(
|
||||
Object.keys(runnable?.fields ?? {}).filter(
|
||||
(k) =>
|
||||
runnable?.fields?.[k]?.type == 'user' && runnable?.fields?.[k]?.sensitive
|
||||
)
|
||||
)
|
||||
: undefined,
|
||||
force_viewer_delete_after_secs: editor ? runnable?.delete_after_secs : undefined,
|
||||
on_behalf_of: editor ? previewOnBehalfOf : undefined,
|
||||
viewer_data_access: editor ? runnable?.viewer_data_access || undefined : undefined
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
editor ? rawAppPreviewModules(runnables, runnable_id) : undefined
|
||||
)
|
||||
} catch (e) {
|
||||
// A run refused before it started still answers the app, which would otherwise
|
||||
// wait for ever; the refusal's body says why (a 409 names the turn still running).
|
||||
const body = (e as { body?: unknown })?.body
|
||||
const message =
|
||||
typeof body === 'string'
|
||||
? body
|
||||
: body
|
||||
? JSON.stringify(body)
|
||||
: String((e as Error)?.message ?? e)
|
||||
respond({ result: { message }, error: true })
|
||||
return
|
||||
}
|
||||
launchedJobs.add(uuid)
|
||||
let job: JobById = { component: runnable_id, created_at: Date.now(), job: uuid }
|
||||
if (data.type == 'backendAsync') {
|
||||
@@ -209,97 +229,105 @@
|
||||
)
|
||||
return
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
params.set('fast', 'true')
|
||||
params.set('only_result', 'true')
|
||||
let lastOffset: number | undefined
|
||||
const connect = (streamOffset?: number) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('fast', 'true')
|
||||
params.set('only_result', 'true')
|
||||
if (streamOffset !== undefined) params.set('stream_offset', String(streamOffset))
|
||||
|
||||
const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?${params.toString()}`
|
||||
const eventSource = new EventSource(sseUrl)
|
||||
const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?${params.toString()}`
|
||||
const eventSource = new EventSource(sseUrl)
|
||||
|
||||
eventSource.onmessage = (sseEvent) => {
|
||||
try {
|
||||
const update = JSON.parse(sseEvent.data)
|
||||
const type = update.type
|
||||
eventSource.onmessage = (sseEvent) => {
|
||||
try {
|
||||
const update = JSON.parse(sseEvent.data)
|
||||
const type = update.type
|
||||
|
||||
if (type === 'ping' || type === 'timeout') {
|
||||
if (type === 'timeout') {
|
||||
eventSource.close()
|
||||
if (type === 'ping' || type === 'timeout') {
|
||||
if (type === 'timeout') {
|
||||
// The server ends every stream after a while; the job goes on, so it is resumed.
|
||||
eventSource.close()
|
||||
if (!destroyed) connect(lastOffset)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
eventSource.close()
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobRes',
|
||||
reqId,
|
||||
error: true,
|
||||
result: { message: update.error || 'SSE error' }
|
||||
},
|
||||
'*'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (type === 'error') {
|
||||
eventSource.close()
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobRes',
|
||||
reqId,
|
||||
error: true,
|
||||
result: { message: update.error || 'SSE error' }
|
||||
},
|
||||
'*'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'not_found') {
|
||||
eventSource.close()
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobRes',
|
||||
reqId,
|
||||
error: true,
|
||||
result: { message: 'Job not found' }
|
||||
},
|
||||
'*'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (type === 'not_found') {
|
||||
eventSource.close()
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobRes',
|
||||
reqId,
|
||||
error: true,
|
||||
result: { message: 'Job not found' }
|
||||
},
|
||||
'*'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Send stream update if there's new stream data
|
||||
if (update.new_result_stream !== undefined) {
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobUpdate',
|
||||
reqId,
|
||||
new_result_stream: update.new_result_stream,
|
||||
stream_offset: update.stream_offset
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
// Send stream update if there's new stream data
|
||||
if (update.stream_offset !== undefined) lastOffset = update.stream_offset
|
||||
if (update.new_result_stream !== undefined) {
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobUpdate',
|
||||
reqId,
|
||||
new_result_stream: update.new_result_stream,
|
||||
stream_offset: update.stream_offset
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
|
||||
// Check if job is completed
|
||||
if (update.completed) {
|
||||
eventSource.close()
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobRes',
|
||||
reqId,
|
||||
error: false,
|
||||
result: update.only_result
|
||||
},
|
||||
'*'
|
||||
)
|
||||
// Check if job is completed
|
||||
if (update.completed) {
|
||||
eventSource.close()
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobRes',
|
||||
reqId,
|
||||
error: false,
|
||||
result: update.only_result
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.warn('Failed to parse SSE data:', parseErr)
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.warn('Failed to parse SSE data:', parseErr)
|
||||
}
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.warn('SSE stream error:', error)
|
||||
eventSource.close()
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobRes',
|
||||
reqId,
|
||||
error: true,
|
||||
result: { message: 'SSE connection error' }
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.warn('SSE stream error:', error)
|
||||
eventSource.close()
|
||||
sourceWindow?.postMessage(
|
||||
{
|
||||
type: 'streamJobRes',
|
||||
reqId,
|
||||
error: true,
|
||||
result: { message: 'SSE connection error' }
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
connect()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
import { getAppliedDarkModeVariant, type DarkModeVariant } from '$lib/darkModeVariant'
|
||||
import RawAppSidebar from './RawAppSidebar.svelte'
|
||||
import type { Modules } from './RawAppModules.svelte'
|
||||
import { isRunnableByName, isRunnableByPath } from '../apps/inputType'
|
||||
import { isRunnableByName, isRunnableByPath, type PathRunType } from '../apps/inputType'
|
||||
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
|
||||
import { onMount, onDestroy, untrack } from 'svelte'
|
||||
import type {
|
||||
@@ -917,6 +917,8 @@ addEventListener('message', function (e) {
|
||||
backendRunnable.type = 'flow'
|
||||
} else if (runnable.runType === 'hubscript') {
|
||||
backendRunnable.type = 'hubscript'
|
||||
} else if (runnable.runType === 'agent') {
|
||||
backendRunnable.type = 'agent'
|
||||
} else {
|
||||
backendRunnable.type = 'script'
|
||||
}
|
||||
@@ -1230,7 +1232,7 @@ addEventListener('message', function (e) {
|
||||
runnables[key] = {
|
||||
name: runnable.name,
|
||||
type: 'path',
|
||||
runType: runnable.type as 'script' | 'flow' | 'hubscript',
|
||||
runType: runnable.type as PathRunType,
|
||||
path: runnable.path,
|
||||
fields: runnable.staticInputs
|
||||
? Object.fromEntries(
|
||||
@@ -1861,7 +1863,7 @@ addEventListener('message', function (e) {
|
||||
|
||||
// The deployed app's `window.ctx`: the viewer's membership in the app's workspace.
|
||||
function previewCtx() {
|
||||
return $state.snapshot({ ctx: operatingUser.current, workspace: opWorkspace })
|
||||
return $state.snapshot({ ctx: operatingUser.current, workspace: opWorkspace, path })
|
||||
}
|
||||
|
||||
// A build fed before the viewer is known keeps that viewer until the next one. Outside
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import SchemaForm from '../SchemaForm.svelte'
|
||||
import RunnableJobPanelInner from '../apps/editor/RunnableJobPanelInner.svelte'
|
||||
import JobLoader from '../JobLoader.svelte'
|
||||
import { FlowService, type Job, type OpenFlow, type ScriptLang } from '$lib/gen'
|
||||
import { FlowService, JobService, type Job, type OpenFlow, type ScriptLang } from '$lib/gen'
|
||||
import { getScriptByPath } from '$lib/scripts'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { DebugToolbar, DebugPanel, debugState } from '$lib/components/debug'
|
||||
@@ -224,6 +224,22 @@
|
||||
} else {
|
||||
await jobLoader.runFlowByPath(runnable.path, args)
|
||||
}
|
||||
} else if (runnable.runType == 'agent') {
|
||||
// A turn of the agent as it is deployed, as the editor: the direct run has no way
|
||||
// to ask for the app's identity.
|
||||
const path = runnable.path
|
||||
const { user_message, memory_id, user_attachments, previous_messages } = args as Record<
|
||||
string,
|
||||
any
|
||||
>
|
||||
await jobLoader.abstractRun(() =>
|
||||
JobService.runAgent({
|
||||
workspace: opWs!,
|
||||
path,
|
||||
memoryId: memory_id || undefined,
|
||||
requestBody: { user_message, user_attachments, previous_messages }
|
||||
})
|
||||
)
|
||||
} else if (runnable.runType == 'script' || runnable.runType == 'hubscript') {
|
||||
if (previewOnBehalfOf) {
|
||||
const script = await getScriptByPath(runnable.path, opWs)
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
const html = unsandboxedRawAppHtml(
|
||||
workspace,
|
||||
secret,
|
||||
{ ctx: u, workspace },
|
||||
{ ctx: u, workspace, path },
|
||||
window.location.origin,
|
||||
pageHash()
|
||||
)
|
||||
@@ -207,7 +207,7 @@
|
||||
// Same shape as the unsandboxed wrapper: always the object, so
|
||||
// `window.ctx.workspace` works for anonymous viewers too. Snapshotted: a
|
||||
// caller's `$state` user is a proxy, which postMessage cannot clone.
|
||||
ctx: { ctx: $state.snapshot(user), workspace },
|
||||
ctx: { ctx: $state.snapshot(user), workspace, path },
|
||||
initialHash,
|
||||
storage: { local: bundleStorage ?? {}, session: {} },
|
||||
// The wrapper turns this into `window.process.env` before it injects the
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Schema, SchemaProperty } from '$lib/common'
|
||||
import { agentMemoryMode, keepsManagedMemory } from '$lib/components/flows/agentFormFields'
|
||||
|
||||
/**
|
||||
* The inputs of a raw app's agent runnable, after the agent's memory as its own page reads it: a
|
||||
* chat (`memory_id`, the conversation and memory key) when it keeps managed memory, single runs
|
||||
* answered from the history the app passes (`previous_messages`) when it is off, and neither for an
|
||||
* older `manual` setting (`memory_id` is the run's, which an older `auto` one keeps memory under).
|
||||
* The server accepts both either way (see `execute_component`), so a schema gone stale only
|
||||
* mistypes the inputs.
|
||||
*/
|
||||
export function agentRunnableSchema(memory: unknown): Schema {
|
||||
const history: Record<string, SchemaProperty> = {}
|
||||
if (keepsManagedMemory(memory)) {
|
||||
history.memory_id = {
|
||||
type: 'string',
|
||||
description:
|
||||
'The conversation the message belongs to: reuse it to continue one, or omit it for a single run'
|
||||
}
|
||||
}
|
||||
if (agentMemoryMode(memory) === 'off') {
|
||||
history.previous_messages = {
|
||||
type: 'array',
|
||||
description: 'The history to answer from, as chat messages: this agent keeps no memory',
|
||||
items: { type: 'object' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_message: { type: 'string', description: 'The message to answer' },
|
||||
...history,
|
||||
user_attachments: {
|
||||
type: 'array',
|
||||
description: 'Files the message carries, as object storage references',
|
||||
items: { type: 'object', resourceType: 's3object' }
|
||||
}
|
||||
},
|
||||
required: ['user_message'],
|
||||
order: ['user_message', ...Object.keys(history), 'user_attachments']
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,8 @@ function runnableRef(node: Record<string, any>): Ref | undefined {
|
||||
if (!isRunnableByPath(node as any) || typeof node.path !== 'string') return undefined
|
||||
if (node.runType === 'flow') return { kind: 'flow', path: node.path }
|
||||
if (node.runType === 'script') return { kind: 'script', path: node.path }
|
||||
// A saved agent is an `ai_agent` resource.
|
||||
if (node.runType === 'agent') return { kind: 'resource', path: node.path }
|
||||
return undefined // hubscript -> external hub, ignored
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
ScriptService,
|
||||
ResourceService,
|
||||
type MainArgSignature,
|
||||
FlowService,
|
||||
type Script,
|
||||
type AssetUsageAccessType
|
||||
} from '$lib/gen'
|
||||
import { get, writable } from 'svelte/store'
|
||||
import type { PathRunType } from './components/apps/inputType.js'
|
||||
import { agentRunnableSchema } from './components/raw_apps/agentRunnable.js'
|
||||
import type { Schema, SupportedLanguage } from './common.js'
|
||||
import {
|
||||
type DynamicInput,
|
||||
@@ -658,9 +661,15 @@ async function inferSchemaIfNecessary(script: Script) {
|
||||
export async function loadSchema(
|
||||
workspace: string,
|
||||
path: string,
|
||||
runType: 'script' | 'flow' | 'hubscript'
|
||||
runType: PathRunType
|
||||
): Promise<{ schema: Schema; summary: string | undefined }> {
|
||||
if (runType === 'script') {
|
||||
if (runType === 'agent') {
|
||||
const agent = await ResourceService.getResource({ workspace, path })
|
||||
return {
|
||||
schema: agentRunnableSchema((agent.value as any)?.memory),
|
||||
summary: agent.description || undefined
|
||||
}
|
||||
} else if (runType === 'script') {
|
||||
const script = await ScriptService.getScriptByPath({
|
||||
workspace,
|
||||
path
|
||||
|
||||
@@ -1054,7 +1054,7 @@ An app can be demoed by recording a session: every interaction becomes a step ca
|
||||
|
||||
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
|
||||
|
||||
### Chat UIs over a flow in chat mode
|
||||
### Chat UIs over a flow in chat mode or a saved agent
|
||||
|
||||
A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`@windmill-labs/chat-sdk\` to \`package.json\` and use it directly, it detects the app's Windmill and credential.
|
||||
|
||||
@@ -1067,9 +1067,26 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' })
|
||||
|
||||
\`@windmill-labs/chat-sdk/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`@windmill-labs/chat-sdk/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser.
|
||||
|
||||
#### A saved agent: an \`agent\` runnable
|
||||
|
||||
Chat with a saved AI agent (an \`ai_agent\` resource) through an \`agent\` runnable, a path runnable whose \`path\` is the agent's. Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no frontend SDK scope, viewers get no consent prompt and need no access to the agent. Prefer it in every app, sandboxed or not:
|
||||
|
||||
\`\`\`json
|
||||
{ "chat": { "type": "agent", "path": "f/support/agent" } }
|
||||
\`\`\`
|
||||
|
||||
\`\`\`tsx
|
||||
import { useWindmillChat } from '@windmill-labs/chat-sdk/react'
|
||||
import * as wmill from './wmill'
|
||||
|
||||
const chat = useWindmillChat({ wmill, runnable: 'chat' })
|
||||
\`\`\`
|
||||
|
||||
\`runnable\` is the runnable's key; each runnable keeps its own conversations. The runnable takes \`user_message\` and \`memory_id\`, the conversation id, which the chat fills in: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as \`backend.<key>({ user_message })\` without \`memory_id\`, it is a single run with no conversation, for a one-off question. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes \`previous_messages\` (chat messages) instead of \`memory_id\`, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. \`chat.stop()\` stops following without cancelling the turn, and a message sent while it still runs is refused with \`TurnRunningError\`. Attachments are not supported this way.
|
||||
|
||||
## Backend runnables
|
||||
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of four types:
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of five types:
|
||||
|
||||
| Type | What it is |
|
||||
|---|---|
|
||||
@@ -1077,6 +1094,7 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo
|
||||
| \`script\` | Reference to an existing workspace script by path. |
|
||||
| \`flow\` | Reference to an existing workspace flow by path. |
|
||||
| \`hubscript\` | Reference to a hub script by path. |
|
||||
| \`agent\` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). |
|
||||
|
||||
### Inline runnables
|
||||
|
||||
@@ -1117,9 +1135,9 @@ An inline runnable runs as an ordinary Windmill job. \`import * as wmill from 'w
|
||||
|
||||
Use only \`wmill\` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (\`JobService\`, \`ScriptService\`, ...) are importable from \`windmill-client\`. What is not available is a name you guessed at: \`getBaseUrl\` and \`getWorkspaceToken\` are inventions, not API.
|
||||
|
||||
### Path runnables (script / flow / hubscript)
|
||||
### Path runnables (script / flow / hubscript / agent)
|
||||
|
||||
When \`type\` is \`script\`, \`flow\`, or \`hubscript\`, the runnable just stores a \`path\` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface.
|
||||
When \`type\` is \`script\`, \`flow\`, \`hubscript\` or \`agent\`, the runnable just stores a \`path\` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is \`user_message\` and \`user_attachments\`, plus \`memory_id\` when the agent keeps managed memory or \`previous_messages\` when its memory is off.
|
||||
|
||||
Before writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at \`hub/<version>/<app>/<name>\`) for a third-party service, and reference it instead of copying its logic.
|
||||
|
||||
@@ -1128,7 +1146,7 @@ Before writing an inline runnable, look for a workspace script or flow that alre
|
||||
This decides whether an app works before anything is deployed:
|
||||
|
||||
- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.
|
||||
- **Path runnables (\`script\` / \`flow\` / \`hubscript\`) run the DEPLOYED item at that path.** So do \`wmill.runFlow\`, \`wmill.runFlowAsync\` and \`wmill.runScriptByPath\` called from inside a runnable. A draft — including a draft you just created — does not exist for them.
|
||||
- **Path runnables (\`script\` / \`flow\` / \`hubscript\` / \`agent\`) run the DEPLOYED item at that path.** So do \`wmill.runFlow\`, \`wmill.runFlowAsync\` and \`wmill.runScriptByPath\` called from inside a runnable. A draft — including a draft you just created — does not exist for them.
|
||||
|
||||
So an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.
|
||||
|
||||
@@ -1227,7 +1245,7 @@ def main(user_id: str):
|
||||
7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable.
|
||||
9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.
|
||||
10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.
|
||||
10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow or a saved agent** — never hand-roll a runnable that runs it and polls its stream; a saved agent is an \`agent\` runnable passed as \`{ wmill, runnable }\`.
|
||||
`;
|
||||
|
||||
export const PIPELINE_BASE = `# Data pipeline authoring
|
||||
|
||||
@@ -380,7 +380,7 @@ An app can be demoed by recording a session: every interaction becomes a step ca
|
||||
|
||||
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
|
||||
|
||||
### Chat UIs over a flow in chat mode
|
||||
### Chat UIs over a flow in chat mode or a saved agent
|
||||
|
||||
A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.
|
||||
|
||||
@@ -393,9 +393,31 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' })
|
||||
|
||||
`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.
|
||||
|
||||
#### A saved agent: an `agent` runnable
|
||||
|
||||
Chat with a saved AI agent (an `ai_agent` resource) through an `agent` runnable, a path runnable whose `path` is the agent's. Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no frontend SDK scope, viewers get no consent prompt and need no access to the agent. Prefer it in every app, sandboxed or not:
|
||||
|
||||
In `raw_app.yaml`:
|
||||
|
||||
```yaml
|
||||
runnables:
|
||||
chat:
|
||||
type: agent
|
||||
path: f/support/agent
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { useWindmillChat } from '@windmill-labs/chat-sdk/react'
|
||||
import * as wmill from './wmill'
|
||||
|
||||
const chat = useWindmillChat({ wmill, runnable: 'chat' })
|
||||
```
|
||||
|
||||
`runnable` is the runnable's key; each runnable keeps its own conversations. The runnable takes `user_message` and `memory_id`, the conversation id, which the chat fills in: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as `backend.<key>({ user_message })` without `memory_id`, it is a single run with no conversation, for a one-off question. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead of `memory_id`, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. `chat.stop()` stops following without cancelling the turn, and a message sent while it still runs is refused with `TurnRunningError`. Attachments are not supported this way.
|
||||
|
||||
## Backend runnables
|
||||
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of four types:
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of five types:
|
||||
|
||||
| Type | What it is |
|
||||
|---|---|
|
||||
@@ -403,6 +425,7 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo
|
||||
| `script` | Reference to an existing workspace script by path. |
|
||||
| `flow` | Reference to an existing workspace flow by path. |
|
||||
| `hubscript` | Reference to a hub script by path. |
|
||||
| `agent` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). |
|
||||
|
||||
### Inline runnables
|
||||
|
||||
@@ -444,9 +467,9 @@ An inline runnable runs as an ordinary Windmill job. `import * as wmill from 'wi
|
||||
|
||||
Use only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.
|
||||
|
||||
### Path runnables (script / flow / hubscript)
|
||||
### Path runnables (script / flow / hubscript / agent)
|
||||
|
||||
When `type` is `script`, `flow`, or `hubscript`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface.
|
||||
When `type` is `script`, `flow`, `hubscript` or `agent`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is `user_message` and `user_attachments`, plus `memory_id` when the agent keeps managed memory or `previous_messages` when its memory is off.
|
||||
|
||||
Before writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.
|
||||
|
||||
@@ -455,7 +478,7 @@ Before writing an inline runnable, look for a workspace script or flow that alre
|
||||
This decides whether an app works before anything is deployed:
|
||||
|
||||
- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.
|
||||
- **Path runnables (`script` / `flow` / `hubscript`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.
|
||||
- **Path runnables (`script` / `flow` / `hubscript` / `agent`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.
|
||||
|
||||
So an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.
|
||||
|
||||
@@ -565,4 +588,4 @@ def main(user_id: str):
|
||||
7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.
|
||||
9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.
|
||||
10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.
|
||||
10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow or a saved agent** — never hand-roll a runnable that runs it and polls its stream; a saved agent is an `agent` runnable passed as `{ wmill, runnable }`.
|
||||
|
||||
@@ -139,7 +139,7 @@ An app can be demoed by recording a session: every interaction becomes a step ca
|
||||
|
||||
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
|
||||
|
||||
### Chat UIs over a flow in chat mode
|
||||
### Chat UIs over a flow in chat mode or a saved agent
|
||||
|
||||
A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.
|
||||
|
||||
@@ -152,9 +152,38 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' })
|
||||
|
||||
`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.
|
||||
|
||||
#### A saved agent: an `agent` runnable
|
||||
|
||||
Chat with a saved AI agent (an `ai_agent` resource) through an `agent` runnable, a path runnable whose `path` is the agent's. Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no frontend SDK scope, viewers get no consent prompt and need no access to the agent. Prefer it in every app, sandboxed or not:
|
||||
|
||||
<!-- cli-only -->
|
||||
In `raw_app.yaml`:
|
||||
|
||||
```yaml
|
||||
runnables:
|
||||
chat:
|
||||
type: agent
|
||||
path: f/support/agent
|
||||
```
|
||||
<!-- /cli-only -->
|
||||
<!-- chat-only -->
|
||||
```json
|
||||
{ "chat": { "type": "agent", "path": "f/support/agent" } }
|
||||
```
|
||||
<!-- /chat-only -->
|
||||
|
||||
```tsx
|
||||
import { useWindmillChat } from '@windmill-labs/chat-sdk/react'
|
||||
import * as wmill from './wmill'
|
||||
|
||||
const chat = useWindmillChat({ wmill, runnable: 'chat' })
|
||||
```
|
||||
|
||||
`runnable` is the runnable's key; each runnable keeps its own conversations. The runnable takes `user_message` and `memory_id`, the conversation id, which the chat fills in: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as `backend.<key>({ user_message })` without `memory_id`, it is a single run with no conversation, for a one-off question. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead of `memory_id`, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. `chat.stop()` stops following without cancelling the turn, and a message sent while it still runs is refused with `TurnRunningError`. Attachments are not supported this way.
|
||||
|
||||
## Backend runnables
|
||||
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of four types:
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of five types:
|
||||
|
||||
| Type | What it is |
|
||||
|---|---|
|
||||
@@ -162,6 +191,7 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo
|
||||
| `script` | Reference to an existing workspace script by path. |
|
||||
| `flow` | Reference to an existing workspace flow by path. |
|
||||
| `hubscript` | Reference to a hub script by path. |
|
||||
| `agent` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). |
|
||||
|
||||
### Inline runnables
|
||||
|
||||
@@ -205,9 +235,9 @@ An inline runnable runs as an ordinary Windmill job. `import * as wmill from 'wi
|
||||
|
||||
Use only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.
|
||||
|
||||
### Path runnables (script / flow / hubscript)
|
||||
### Path runnables (script / flow / hubscript / agent)
|
||||
|
||||
When `type` is `script`, `flow`, or `hubscript`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface.
|
||||
When `type` is `script`, `flow`, `hubscript` or `agent`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is `user_message` and `user_attachments`, plus `memory_id` when the agent keeps managed memory or `previous_messages` when its memory is off.
|
||||
|
||||
Before writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.
|
||||
|
||||
@@ -216,7 +246,7 @@ Before writing an inline runnable, look for a workspace script or flow that alre
|
||||
This decides whether an app works before anything is deployed:
|
||||
|
||||
- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.
|
||||
- **Path runnables (`script` / `flow` / `hubscript`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.
|
||||
- **Path runnables (`script` / `flow` / `hubscript` / `agent`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.
|
||||
|
||||
So an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.
|
||||
|
||||
@@ -345,4 +375,4 @@ def main(user_id: str):
|
||||
7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.
|
||||
9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.
|
||||
10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.
|
||||
10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow or a saved agent** — never hand-roll a runnable that runs it and polls its stream; a saved agent is an `agent` runnable passed as `{ wmill, runnable }`.
|
||||
|
||||
Reference in New Issue
Block a user