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:
hugocasa
2026-10-27 23:51:35 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 003a6a9fd6
commit 07d4f7fe61
29 changed files with 263 additions and 131 deletions
+6 -2
View File
@@ -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>;',
);
});
});
+2 -2
View File
@@ -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(
+126
View File
@@ -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,
+47 -32
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: [],
+4 -4
View File
@@ -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 })
}
}
})
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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");
+4 -4
View File
@@ -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*\}[,)]/);
});
});
+2 -2
View File
@@ -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 }, ')
})
})
+17 -3
View File
@@ -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 -1
View File
@@ -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",
+4 -4
View File
@@ -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 })
}
}
}
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.