mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 08:02:43 +00:00
feat: app conversations through the memory_id query parameter (#12025)
* feat: app conversations through the memory_id query parameter, chat-mode flows included Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: regenerate the mcp session instructions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: type backendAsync's memoryId option in the eval diagnostics Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: pin git-sync hub scripts to windmill-cli 1.849.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: memoryId option on the sync backend call too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: bump the ui builder artifact for the memoryId option Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: shared-utils 1.0.14 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: cli takes shared-utils 1.0.14 for the memoryId option Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: pin the app conversation scoping and the memoryId signatures Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: assert the ownership refusal, not any refusal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: match the memoryId option in the cli wmill.ts signature tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: file an app's turns as the deployed flow's conversation, preview included 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
003a6a9fd6
commit
07d4f7fe61
@@ -90,7 +90,11 @@ describe("buildAppWmillTypes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(wmillTypes).toContain('"listRecipes": () => Promise<any>;');
|
||||
expect(wmillTypes).toContain('"addRecipe": (args: { name: string }) => Promise<any>;');
|
||||
expect(wmillTypes).toContain(
|
||||
'"listRecipes": (args?: {}, opts?: { memoryId?: string }) => Promise<any>;',
|
||||
);
|
||||
expect(wmillTypes).toContain(
|
||||
'"addRecipe": (args: { name: string }, opts?: { memoryId?: string }) => Promise<any>;',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -567,9 +567,9 @@ function getRunnableSignature(
|
||||
runnable: AppDiagnosticRunnable | undefined,
|
||||
asyncMode: boolean,
|
||||
): string {
|
||||
const returnType = asyncMode ? "Promise<string>" : "Promise<any>";
|
||||
const parameter = getRunnableParameterSignature(runnable);
|
||||
return `${parameter} => ${returnType}`;
|
||||
const opts = "opts?: { memoryId?: string }";
|
||||
return `${parameter === "()" ? `(args?: {}, ${opts})` : parameter.replace(/\)$/, `, ${opts})`)} => Promise<${asyncMode ? "string" : "any"}>`;
|
||||
}
|
||||
|
||||
function getRunnableParameterSignature(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! An app viewer names a flow's memory with `run_query_params.memory_id`, and that id must
|
||||
//! never reach another viewer's memory: a chat-mode flow files the turn in a conversation
|
||||
//! owned by the viewer, and any other flow keys its memory per app and viewer.
|
||||
//!
|
||||
//! Users from the `base` fixture:
|
||||
//! test-user (admin, token SECRET_TOKEN)
|
||||
//! test-user-2 (developer, token SECRET_TOKEN_2)
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const TOKENS: [&str; 2] = ["SECRET_TOKEN", "SECRET_TOKEN_2"];
|
||||
const MEMORY_ID: &str = "8b7c3d52-0a4e-4f6b-9d1a-2c3e4f5a6b7c";
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
/// Deploys a flow and an app that runs it.
|
||||
async fn setup(ws: &str, flow_path: &str, chat: bool) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = authed(client.post(format!("{ws}/flows/create")), TOKENS[0])
|
||||
.json(&json!({
|
||||
"path": flow_path,
|
||||
"summary": "",
|
||||
"value": { "modules": [], "chat_input_enabled": chat },
|
||||
"schema": { "type": "object", "properties": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.status().is_success(), "{}", resp.text().await?);
|
||||
let resp = authed(client.post(format!("{ws}/apps/create")), TOKENS[0])
|
||||
.json(&json!({
|
||||
"path": format!("{flow_path}_app"),
|
||||
"summary": "",
|
||||
"value": {},
|
||||
"policy": {
|
||||
"execution_mode": "anonymous",
|
||||
"triggerables_v2": {
|
||||
format!("run:flow/{flow_path}"): { "static_inputs": {}, "one_of_inputs": {} }
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.status().is_success(), "{}", resp.text().await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(
|
||||
ws: &str,
|
||||
token: &str,
|
||||
flow_path: &str,
|
||||
preview: bool,
|
||||
) -> anyhow::Result<reqwest::Response> {
|
||||
let mut body = json!({
|
||||
"component": "run",
|
||||
"path": format!("flow/{flow_path}"),
|
||||
"args": { "user_message": "hi" },
|
||||
"run_query_params": { "memory_id": MEMORY_ID }
|
||||
});
|
||||
if preview {
|
||||
body["force_viewer_static_fields"] = json!({});
|
||||
}
|
||||
Ok(authed(
|
||||
reqwest::Client::new().post(format!("{ws}/apps_u/execute_component/{flow_path}_app")),
|
||||
token,
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?)
|
||||
}
|
||||
|
||||
// One test, one database: the server caches flow versions by id across the tests of a
|
||||
// process, and two fresh databases number their first flow version alike.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_viewer_cannot_reach_anothers_flow_memory(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let ws = format!(
|
||||
"http://localhost:{}/api/w/test-workspace",
|
||||
server.addr.port()
|
||||
);
|
||||
|
||||
let plain = "u/test-user/plain";
|
||||
setup(&ws, plain, false).await?;
|
||||
let mut keys = vec![];
|
||||
for token in TOKENS {
|
||||
let resp = run(&ws, token, plain, false).await?;
|
||||
assert!(resp.status().is_success(), "{}", resp.text().await?);
|
||||
let job_id: uuid::Uuid = resp.text().await?.parse()?;
|
||||
let key: Option<String> =
|
||||
sqlx::query_scalar("SELECT flow_status->>'memory_id' FROM v2_job_status WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
keys.push(key.expect("the run keeps a memory key"));
|
||||
}
|
||||
assert_ne!(
|
||||
keys[0], keys[1],
|
||||
"two viewers naming one id share no memory"
|
||||
);
|
||||
assert!(
|
||||
keys.iter().all(|k| k != MEMORY_ID),
|
||||
"the named id is never the key"
|
||||
);
|
||||
|
||||
let chat = "u/test-user/chat";
|
||||
setup(&ws, chat, true).await?;
|
||||
// From the app editor's preview: the app still runs the deployed flow, so the
|
||||
// conversation is the deployed flow's.
|
||||
let resp = run(&ws, TOKENS[0], chat, true).await?;
|
||||
assert!(resp.status().is_success(), "{}", resp.text().await?);
|
||||
let (owner, is_test): (String, bool) =
|
||||
sqlx::query_as("SELECT created_by, is_test FROM flow_conversation WHERE id = $1::uuid")
|
||||
.bind(MEMORY_ID)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!((owner.as_str(), is_test), ("test-user", false));
|
||||
let resp = run(&ws, TOKENS[1], chat, false).await?;
|
||||
// The first turn never runs here, so a refusal for the turn still running would pass too.
|
||||
let body = resp.text().await?;
|
||||
assert!(body.contains("belongs to another user"), "{body}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -891,20 +891,6 @@ pub async fn set_flow_memory_id(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply flow-specific query parameters after job creation
|
||||
pub async fn process_flow_run_query_params(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
job_id: Uuid,
|
||||
w_id: &str,
|
||||
flow_path: &str,
|
||||
run_query: &RunJobQuery,
|
||||
) -> error::Result<()> {
|
||||
if let Some(memory_id) = run_query.memory_key(w_id, flow_path) {
|
||||
set_flow_memory_id(tx, job_id, memory_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_chat_conversation_messages(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
authed: &ApiAuthed,
|
||||
|
||||
@@ -4973,35 +4973,28 @@ async fn execute_component(
|
||||
// alone — as it does the policy's own `static_inputs`, which the publisher writes.
|
||||
sanitize_untrusted_args(&mut payload.args);
|
||||
|
||||
// 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.
|
||||
// The conversation a turn belongs to, which names the runnable's memory rather than an input
|
||||
// of its run. Without it an agent's run is a single one, as on the direct agent endpoint.
|
||||
let memory_id = payload
|
||||
.run_query_params
|
||||
.as_ref()
|
||||
.and_then(|q| q.memory_id.clone())
|
||||
.filter(|m| !m.trim().is_empty());
|
||||
|
||||
// An agent runnable takes the message and its files.
|
||||
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());
|
||||
if agent_path.is_some() {
|
||||
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,
|
||||
@@ -5028,6 +5021,17 @@ async fn execute_component(
|
||||
.as_deref()
|
||||
.and_then(|path| path.strip_prefix("flow/"))
|
||||
.map(str::to_string);
|
||||
// A chat-mode flow keeps its conversations as an agent does, and is scoped like one below.
|
||||
let chat_flow_path = match (flow_path.as_deref(), &memory_id) {
|
||||
(Some(p), Some(_)) if !p.starts_with("hub/") => {
|
||||
windmill_common::get_latest_flow_version_info_for_path(None, &db, &w_id, p, true)
|
||||
.await?
|
||||
.chat_input_enabled
|
||||
.unwrap_or(false)
|
||||
.then(|| p.to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Tag for inline-script jobs is read from the deployed policy in run mode;
|
||||
// only preview mode (editor) honors the client-supplied tag. This applies to
|
||||
@@ -5062,7 +5066,7 @@ async fn execute_component(
|
||||
}
|
||||
}
|
||||
// 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 conversation_viewer = memory_id.as_ref().and(opt_authed.clone());
|
||||
let mut agent_run_path = None;
|
||||
|
||||
let component = payload.component.clone();
|
||||
@@ -5085,7 +5089,7 @@ async fn execute_component(
|
||||
&user_db,
|
||||
&w_id,
|
||||
agent,
|
||||
agent_memory_id.is_some(),
|
||||
memory_id.is_some(),
|
||||
)
|
||||
.await?;
|
||||
agent_run_path = Some(turn.run_path.clone());
|
||||
@@ -5239,14 +5243,19 @@ async fn execute_component(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let (Some(run_path), Some(memory_id)) = (agent_run_path.as_deref(), agent_memory_id) {
|
||||
let conversation_path = agent_run_path.as_deref().or(chat_flow_path.as_deref());
|
||||
if let (Some(run_path), Some(memory_id)) = (conversation_path, memory_id.as_deref()) {
|
||||
// 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),
|
||||
let (owner, is_admin, memory_id) = match conversation_viewer.as_ref() {
|
||||
Some(viewer) => (
|
||||
viewer.username.clone(),
|
||||
viewer.is_admin,
|
||||
memory_id.to_string(),
|
||||
),
|
||||
None => (
|
||||
windmill_common::flow_conversations::ANONYMOUS_CONVERSATION_OWNER.to_string(),
|
||||
false,
|
||||
@@ -5271,18 +5280,24 @@ async fn execute_component(
|
||||
&run_query,
|
||||
args.args.get("user_message"),
|
||||
uuid,
|
||||
is_preview,
|
||||
// Never a test conversation, even from the app editor's preview: an app runs the
|
||||
// deployed flow or agent, and a test conversation is one run on the editor's draft.
|
||||
false,
|
||||
&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() {
|
||||
crate::jobs::process_flow_run_query_params(&mut tx, uuid, &w_id, flow_path, run_query)
|
||||
.await?;
|
||||
}
|
||||
} else if let (Some(flow_path), Some(memory_id)) = (flow_path.as_deref(), memory_id) {
|
||||
// A flow keeping no conversation has no row to own its memory, so the key itself is
|
||||
// derived per app and viewer: no viewer can name another's memory.
|
||||
let viewer = conversation_viewer
|
||||
.as_ref()
|
||||
.map_or("anonymous".to_string(), |v| format!("u/{}", v.username));
|
||||
let key = windmill_common::flow_conversations::memory_key(
|
||||
&w_id,
|
||||
flow_path,
|
||||
&format!("app:{path}:{viewer}:{memory_id}"),
|
||||
);
|
||||
crate::jobs::set_flow_memory_id(&mut tx, uuid, key).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -201,7 +201,7 @@ pub enum ObjectType {
|
||||
DatatableMigration,
|
||||
}
|
||||
|
||||
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/29785/sync-script-to-git-repo-windmill";
|
||||
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/30052/sync-script-to-git-repo-windmill";
|
||||
|
||||
/// Hub script that applies a repository's state back into a workspace
|
||||
/// (the repo → Windmill / "pull" direction). Same script the UI runs from
|
||||
@@ -209,7 +209,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/29785/sync-script-to-git-repo
|
||||
/// ignores the slug, so the slug is kept free of characters that would be
|
||||
/// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened
|
||||
/// reverse proxies reject as double-encoding when the client re-encodes it).
|
||||
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/29784/git-sync-init-repository-windmill";
|
||||
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/30051/git-sync-init-repository-windmill";
|
||||
|
||||
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
|
||||
/// fork of another workspace.
|
||||
|
||||
+3
-3
@@ -177,9 +177,9 @@ history, so two agent runnables in one app keep separate conversations. Going th
|
||||
`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
|
||||
does not. The runnable takes `user_message`, and the chat passes the conversation id as
|
||||
`backendAsync`'s `memoryId` option, which the agent's memory is keyed by, so it remembers
|
||||
earlier turns (called from `wmill.ts` without it, 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
|
||||
|
||||
@@ -2,19 +2,19 @@ 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.
|
||||
* `run` and `follow` over a raw app's chat runnable (a saved AI agent, or a flow in chat mode),
|
||||
* behind the `runnable` option. The conversation id goes as `memoryId`, the run's `memory_id`
|
||||
* query parameter, which keys the conversation and its memory.
|
||||
*/
|
||||
export function rawAppRunnable(
|
||||
// The generated signature is the runnable's own.
|
||||
start: (args: any) => Promise<string>,
|
||||
start: (args: any, opts: { memoryId: string }) => 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 }))
|
||||
return String(await start(args, { memoryId: 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)
|
||||
|
||||
@@ -7,13 +7,13 @@ 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) => {
|
||||
const start = async (args: unknown, opts: { memoryId: string }) => {
|
||||
// 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)
|
||||
calls.push([args, opts])
|
||||
return `job-${calls.length}`
|
||||
}
|
||||
const streamJob = async (jobId: string, onUpdate?: (u: { new_result_stream?: string }) => void) => {
|
||||
@@ -36,8 +36,8 @@ describe('raw app agent runnable', () => {
|
||||
await chat.sendMessage('again')
|
||||
const conversationId = chat.getState().conversationId
|
||||
expect(calls).toEqual([
|
||||
{ user_message: 'hi', memory_id: conversationId },
|
||||
{ user_message: 'again', memory_id: conversationId }
|
||||
[{ user_message: 'hi' }, { memoryId: conversationId }],
|
||||
[{ user_message: 'again' }, { memoryId: conversationId }]
|
||||
])
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'answer job-1', 'again', 'answer job-2'])
|
||||
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
"@cliffy/command": "npm:@jsr/cliffy__command@1.0.0",
|
||||
"@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0",
|
||||
"@cliffy/table": "npm:@jsr/cliffy__table@1.0.0",
|
||||
"@windmill-labs/shared-utils": "^1.0.13",
|
||||
"@windmill-labs/shared-utils": "^1.0.14",
|
||||
"diff": "^5.2.0",
|
||||
"esbuild": "0.28.2",
|
||||
"get-port": "7.1.0",
|
||||
@@ -175,7 +175,7 @@
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@windmill-labs/shared-utils": ["@windmill-labs/shared-utils@1.0.13", "", {}, "sha512-LB3bFq8i0bS2fwM8EqGRODrR1CXLXXyiNa766By1ZbSJYCmTzK8NVXqcluJP1ne7is80LRVZdOgWTw6YX+61Dg=="],
|
||||
"@windmill-labs/shared-utils": ["@windmill-labs/shared-utils@1.0.14", "", {}, "sha512-sD8G8EFQW+ml66kbsdvazDNOb96j9te88HHpwJDrJmNU5KXiuQuGkDo8beZgQ6SvDubY72pZjz5eJBIrbIXPWQ=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"@cliffy/command": "npm:@jsr/cliffy__command@1.0.0",
|
||||
"@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0",
|
||||
"@cliffy/table": "npm:@jsr/cliffy__table@1.0.0",
|
||||
"@windmill-labs/shared-utils": "^1.0.13",
|
||||
"@windmill-labs/shared-utils": "^1.0.14",
|
||||
"diff": "^5.2.0",
|
||||
"esbuild": "0.28.2",
|
||||
"get-port": "7.1.0",
|
||||
|
||||
+10
-11
@@ -1155,7 +1155,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
|
||||
colors.cyan(`[WebSocket] Received: ${JSON.stringify(message)}`),
|
||||
);
|
||||
|
||||
const { type, reqId, runnable_id, v, jobId } = message;
|
||||
const { type, reqId, runnable_id, v, jobId, memoryId } = message;
|
||||
|
||||
// Helper to send response
|
||||
const respond = (responseType: string, result: any, error: boolean) => {
|
||||
@@ -1166,6 +1166,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
|
||||
const runAndWaitForResult = async (
|
||||
runnableId: string,
|
||||
args: any,
|
||||
memoryId?: string,
|
||||
): Promise<{ uuid: string; result: any }> => {
|
||||
const runnables = await loadRunnables();
|
||||
const runnable = runnables[runnableId];
|
||||
@@ -1182,6 +1183,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
|
||||
args,
|
||||
appTempRefs,
|
||||
previewModules(runnables, runnableId),
|
||||
memoryId,
|
||||
);
|
||||
log.info(colors.gray(`[backend] Job started: ${uuid}`));
|
||||
|
||||
@@ -1194,7 +1196,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
|
||||
// Run a runnable synchronously and wait for result
|
||||
log.info(colors.blue(`[backend] Running runnable: ${runnable_id}`));
|
||||
try {
|
||||
const { result } = await runAndWaitForResult(runnable_id, v);
|
||||
const { result } = await runAndWaitForResult(runnable_id, v, memoryId);
|
||||
respond("backendRes", result, false);
|
||||
} catch (error: any) {
|
||||
log.error(colors.red(`[backend] Error: ${error.message}`));
|
||||
@@ -1230,6 +1232,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
|
||||
v,
|
||||
appTempRefs,
|
||||
previewModules(runnables, runnable_id),
|
||||
memoryId,
|
||||
);
|
||||
log.info(colors.gray(`[backendAsync] Job started: ${uuid}`));
|
||||
|
||||
@@ -1713,8 +1716,8 @@ export function buildWmillTs(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `agentRunnableSchema`): `previous_messages` when it keeps no memory. The conversation of one
|
||||
* that does is `backendAsync`'s `memoryId` option, not an input.
|
||||
*/
|
||||
function agentRunnableSchema(memory: any) {
|
||||
const managed =
|
||||
@@ -1723,13 +1726,7 @@ function agentRunnableSchema(memory: any) {
|
||||
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) {
|
||||
if (!managed && !legacy) {
|
||||
history.previous_messages = {
|
||||
type: "array",
|
||||
description:
|
||||
@@ -1922,10 +1919,12 @@ async function executeRunnable(
|
||||
args: any,
|
||||
tempScriptRefs?: Record<string, string>,
|
||||
modules?: Record<string, { content: string; language: string }>,
|
||||
memoryId?: string,
|
||||
): Promise<string> {
|
||||
const requestBody: any = {
|
||||
component: runnableId,
|
||||
args: args ?? {},
|
||||
...(memoryId ? { run_query_params: { memory_id: memoryId } } : {}),
|
||||
force_viewer_static_fields: {},
|
||||
force_viewer_one_of_fields: {},
|
||||
force_viewer_allow_user_resources: [],
|
||||
|
||||
@@ -115,8 +115,8 @@ export const backend = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, runnable_id: string) {
|
||||
return (v: any) => {
|
||||
return doRequest('backend', { runnable_id, v })
|
||||
return (v: any, opts?: { memoryId?: string }) => {
|
||||
return doRequest('backend', { runnable_id, v, memoryId: opts?.memoryId })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -125,8 +125,8 @@ export const backendAsync = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, runnable_id: string) {
|
||||
return (v: any) => {
|
||||
return doRequest('backendAsync', { runnable_id, v })
|
||||
return (v: any, opts?: { memoryId?: string }) => {
|
||||
return doRequest('backendAsync', { runnable_id, v, memoryId: opts?.memoryId })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Generated
+2
-2
@@ -6882,7 +6882,7 @@ 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.
|
||||
\`runnable\` is the runnable's key; each runnable keeps its own conversations. The runnable takes \`user_message\`; the chat passes the conversation id as \`backendAsync.<key>(args, { memoryId })\`: 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 })\`, it is a single run with no conversation, for a one-off question; \`backend.<key>(args, { memoryId })\` waits for the reply of a conversation turn. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes \`previous_messages\` (chat messages) instead, 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
|
||||
|
||||
@@ -6938,7 +6938,7 @@ Use only \`wmill\` functions the SDK actually exports; for an endpoint none of t
|
||||
|
||||
### Path runnables (script / flow / hubscript / agent)
|
||||
|
||||
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.
|
||||
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 \`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.
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ test("fetchPathRunnableSchemas pulls schema for a path-based script and feeds wm
|
||||
expect(ts).toContain("userId");
|
||||
expect(ts).toContain("includeProfile");
|
||||
expect(ts).toMatch(/fetchUser:\s*\(args:\s*\{[^}]*userId/);
|
||||
expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}\)/);
|
||||
expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}[,)]/);
|
||||
|
||||
// Sanity: the generated d.ts is syntactically what the dev server writes
|
||||
const outPath = path.join(tempDir, "wmill.d.ts.out");
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("genWmillTs (regression: path-based runnable schema)", () => {
|
||||
expect(ts).toMatch(/fetchUser:\s*\(args:\s*\{[^}]*userId/);
|
||||
expect(ts).toContain("userId");
|
||||
expect(ts).toContain("includeProfile");
|
||||
expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}\)/);
|
||||
expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}[,)]/);
|
||||
});
|
||||
|
||||
test("path-based runnable without schema falls back to {}", async () => {
|
||||
@@ -139,7 +139,7 @@ describe("genWmillTs (regression: path-based runnable schema)", () => {
|
||||
const runnables = await loadRunnablesFromBackend(backendDir);
|
||||
const ts = buildWmillTs(runnables);
|
||||
|
||||
expect(ts).toMatch(/noSchema:\s*\(args:\s*\{\s*\}\)/);
|
||||
expect(ts).toMatch(/noSchema:\s*\(args:\s*\{\s*\}[,)]/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,7 +192,7 @@ describe("inferRunnableSchemaFromFile (regression: new backend-folder format)",
|
||||
// and the runnable would render as `args: {}`.
|
||||
expect(ts).toContain("compute:");
|
||||
expect(ts).toMatch(/compute:\s*\(args:\s*\{[^}]*x:/);
|
||||
expect(ts).not.toMatch(/compute:\s*\(args:\s*\{\s*\}\)/);
|
||||
expect(ts).not.toMatch(/compute:\s*\(args:\s*\{\s*\}[,)]/);
|
||||
});
|
||||
|
||||
test("returns undefined for path-based runnables", async () => {
|
||||
@@ -297,6 +297,6 @@ describe("inferAllInlineSchemas (startup seed for wmill.d.ts)", () => {
|
||||
expect(ts).toMatch(/greet:\s*\(args:\s*\{[^}]*name/);
|
||||
expect(ts).toContain("name");
|
||||
expect(ts).toContain("times");
|
||||
expect(ts).not.toMatch(/greet:\s*\(args:\s*\{\s*\}\)/);
|
||||
expect(ts).not.toMatch(/greet:\s*\(args:\s*\{\s*\}[,)]/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
|
||||
"version": "6880a7f",
|
||||
"sha256": "05d49a6ed4790b444058574ccb650c8d3882161bbf004c0f8c0370964e853367"
|
||||
"version": "693aa1f",
|
||||
"sha256": "13ab69ab94e496e8254f01337eaa5ef9e2197fb0bf64f78100eaab66cb3c1eff"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
const VERSION = '1.0.13'
|
||||
const VERSION = '1.0.14'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
viewer_data_access: editor ? runnable?.viewer_data_access || undefined : undefined
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
data.memoryId ? { memory_id: data.memoryId } : undefined,
|
||||
editor ? rawAppPreviewModules(runnables, runnable_id) : undefined
|
||||
)
|
||||
} catch (e) {
|
||||
|
||||
@@ -228,15 +228,11 @@
|
||||
// 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
|
||||
>
|
||||
const { user_message, 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 }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import type { Schema, SchemaProperty } from '$lib/common'
|
||||
import { agentMemoryMode, keepsManagedMemory } from '$lib/components/flows/agentFormFields'
|
||||
import { agentMemoryMode } 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.
|
||||
* The inputs of a raw app's agent runnable. An agent whose memory is off answers from the history
|
||||
* the app passes (`previous_messages`); one keeping memory takes the conversation as
|
||||
* `backendAsync`'s `memoryId` option, which is not an input. The server accepts
|
||||
* `previous_messages` 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',
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('genWmillTs', () => {
|
||||
|
||||
const dts = genWmillTs(runnables)
|
||||
|
||||
expect(dts).toContain('myflow: (args: { string_input: string }) => Promise<any>;')
|
||||
expect(dts).toContain('myflow: (args: { string_input: string }, opts?: { memoryId?: string }) => Promise<any>;')
|
||||
})
|
||||
|
||||
it('leaves ctx fields out of the args type, the server fills them', () => {
|
||||
@@ -91,7 +91,7 @@ describe('genWmillTs', () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(genWmillTs(runnables)).toContain('myflow: (args: { count?: number }) => Promise<any>;')
|
||||
expect(genWmillTs(runnables)).toContain('myflow: (args: { count?: number }, opts?: { memoryId?: string }) => Promise<any>;')
|
||||
})
|
||||
|
||||
it('quotes flow input names that are not valid identifiers', () => {
|
||||
@@ -112,7 +112,7 @@ describe('genWmillTs', () => {
|
||||
|
||||
// `user-name: string` is unparseable, so the whole generated d.ts is
|
||||
// rejected and every backend call in the editor loses its types.
|
||||
expect(genWmillTs(runnables)).toContain('myflow: (args: { "user-name": string })')
|
||||
expect(genWmillTs(runnables)).toContain('myflow: (args: { "user-name": string }, ')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -238,7 +238,8 @@ export function formatRuntimeLogsForChat(entries: RawAppRuntimeLogEntry[]): stri
|
||||
const BUILD_LOG_TAIL_LINES = 40
|
||||
const BUILD_LOG_TAIL_CHARS = 6000
|
||||
// Per-package install progress: dozens of lines per build that would crowd the errors out of the tail.
|
||||
const ROUTINE_INSTALL_LOG_LINE = /^\s*(Using cached resolution|Using idb cache|Resolved \S+@|Extract(ing|ed) )/
|
||||
const ROUTINE_INSTALL_LOG_LINE =
|
||||
/^\s*(Using cached resolution|Using idb cache|Resolved \S+@|Extract(ing|ed) )/
|
||||
|
||||
export function formatBuildLogTailForChat(buildLogs: string): string {
|
||||
// The bundler log accumulates every install and build since the editor opened.
|
||||
@@ -433,15 +434,28 @@ export function genWmillTs(runnables: Record<string, Runnable>) {
|
||||
return `// THIS FILE IS READ-ONLY
|
||||
// AND GENERATED AUTOMATICALLY FROM YOUR RUNNABLES
|
||||
|
||||
/**
|
||||
* \`memoryId\` names the conversation a turn of an agent or a chat-mode flow belongs to: a
|
||||
* signed-in viewer's conversations are theirs alone.
|
||||
*/
|
||||
export declare const backend: {
|
||||
${Object.entries(runnables)
|
||||
.map(([k, v]) => ` ${k}: (args: ${hiddenRunnableToTsType(v)}) => Promise<any>;`)
|
||||
.map(
|
||||
([k, v]) =>
|
||||
` ${k}: (args: ${hiddenRunnableToTsType(v)}, opts?: { memoryId?: string }) => Promise<any>;`
|
||||
)
|
||||
.join('\n')}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a runnable and resolve its job id. \`memoryId\` is as for \`backend\`.
|
||||
*/
|
||||
export declare const backendAsync: {
|
||||
${Object.entries(runnables)
|
||||
.map(([k, v]) => ` ${k}: (args: ${hiddenRunnableToTsType(v)}) => Promise<string>;`)
|
||||
.map(
|
||||
([k, v]) =>
|
||||
` ${k}: (args: ${hiddenRunnableToTsType(v)}, opts?: { memoryId?: string }) => Promise<string>;`
|
||||
)
|
||||
.join('\n')}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"gitSyncTest": "hub/28950/git-repo-test-read-write-windmill",
|
||||
"gitInitRepo": "hub/29784/git-sync-init-repository-windmill",
|
||||
"gitInitRepo": "hub/30051/git-sync-init-repository-windmill",
|
||||
"slackErrorHandler": "hub/28794/workspace-or-schedule-error-handler-slack",
|
||||
"emailErrorHandler": "hub/19795/workspace-or-error-handler-email",
|
||||
"slackRecoveryHandler": "hub/28791/slack/schedule-recovery-handler-slack",
|
||||
|
||||
@@ -13,8 +13,8 @@ export const backend = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, runnable_id: string) {
|
||||
return (v: any) => {
|
||||
return doRequest('backend', { runnable_id, v })
|
||||
return (v: any, opts?: { memoryId?: string }) => {
|
||||
return doRequest('backend', { runnable_id, v, memoryId: opts?.memoryId })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ export const backendAsync = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, runnable_id: string) {
|
||||
return (v: any) => {
|
||||
return doRequest('backendAsync', { runnable_id, v })
|
||||
return (v: any, opts?: { memoryId?: string }) => {
|
||||
return doRequest('backendAsync', { runnable_id, v, memoryId: opts?.memoryId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,7 +1202,7 @@ 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.
|
||||
\`runnable\` is the runnable's key; each runnable keeps its own conversations. The runnable takes \`user_message\`; the chat passes the conversation id as \`backendAsync.<key>(args, { memoryId })\`: 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 })\`, it is a single run with no conversation, for a one-off question; \`backend.<key>(args, { memoryId })\` waits for the reply of a conversation turn. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes \`previous_messages\` (chat messages) instead, 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
|
||||
|
||||
@@ -1257,7 +1257,7 @@ Use only \`wmill\` functions the SDK actually exports; for an endpoint none of t
|
||||
|
||||
### Path runnables (script / flow / hubscript / agent)
|
||||
|
||||
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.
|
||||
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 \`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.
|
||||
|
||||
|
||||
@@ -413,7 +413,7 @@ 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.
|
||||
`runnable` is the runnable's key; each runnable keeps its own conversations. The runnable takes `user_message`; the chat passes the conversation id as `backendAsync.<key>(args, { memoryId })`: 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 })`, it is a single run with no conversation, for a one-off question; `backend.<key>(args, { memoryId })` waits for the reply of a conversation turn. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead, 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
|
||||
|
||||
@@ -469,7 +469,7 @@ Use only `wmill` functions the SDK actually exports; for an endpoint none of the
|
||||
|
||||
### Path runnables (script / flow / hubscript / agent)
|
||||
|
||||
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.
|
||||
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 `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.
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ 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.
|
||||
`runnable` is the runnable's key; each runnable keeps its own conversations. The runnable takes `user_message`; the chat passes the conversation id as `backendAsync.<key>(args, { memoryId })`: 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 })`, it is a single run with no conversation, for a one-off question; `backend.<key>(args, { memoryId })` waits for the reply of a conversation turn. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead, 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
|
||||
|
||||
@@ -237,7 +237,7 @@ Use only `wmill` functions the SDK actually exports; for an endpoint none of the
|
||||
|
||||
### Path runnables (script / flow / hubscript / agent)
|
||||
|
||||
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.
|
||||
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 `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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user