mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 08:02:44 +00:00
feat: add an AI decision flow step (TypeSafe's Jev, Cloudflare's Clef) (#11474)
* feat: add an AI decision flow step on TypeSafe's Jev An `aidecision` step asks TypeSafe's Jev typed questions (choice, score, yes/no) about a state and returns calibrated answers. It is a plain step: a flow routes on the answers with a Branch to one after it, which the editor builds and keeps in step with the questions. It can also be an AI agent tool, where the calling model supplies the state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: read single-quoted branch conditions and flag branches on a removed question Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: route decision tools by their tag and keep structured questions as JSON The questions editor writes only on an edit, so opening a step leaves its value untouched, and questions the cards cannot hold stay in a JSON editor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: re-pick the questions editor mode when the value is replaced from elsewhere Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the questions JSON editor's text in step with the value Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: recreate the questions JSON editor when the value is replaced from elsewhere Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: run AI decisions on Cloudflare's Clef as well as TypeSafe's Jev A `cloudflare` provider kind reads the hub's `cloudflare` resource (token and account id), calls the model's Workers AI URL, and unwraps Workers AI's `result` envelope. Like TypeSafe, it serves decision steps only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: branch from each AI decision question, and align its editor with the brand guidelines "Branch on this question" now sits in each choice question's card, which also links to the Branch to one that routes it and flags options it misses or branches it no longer matches. Labels, errors, cards and icons follow the form guidelines and the Branch to one's cards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: branch on an AI decision from its graph node A decision with a choice question nothing branches on yet shows a button beside its node that inserts the Branch to one, as its question card does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: resolve Cloudflare resources in the AI proxy, and use the design-system button on the graph Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: put the decision_inputs test's doc comment back on it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * chore: regenerate the flow schema in prompts and the chat after the AiDecision description edit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: switch AI decision questions between the form and JSON JSON is always available; the form is offered whenever its cards can hold the questions, and a value they cannot hold still opens in JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: show step ids as badges and use the JSON toggle of the run forms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: quote option names in the AI decision branch alerts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: remove stale branches from the AI decision question card An empty branch is removed directly; one with steps asks first, naming how many steps go with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: branch on yes/no AI decision questions, and test a decision step from the AI chat A yes/no question branches like a choice: "Branch on this question" writes one "yes" branch on `noul >= 0.5`, with the default as "no", and the helper still reads it once the threshold is tuned. The AI chat's step test runs a decision alone as a one-step preview, taking the state the chat passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: record removing several stale branches as one undo step Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: name the typesafe resource type TypeSafe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: store TypeSafe as the typesafe resource type's display name, not in the word map Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: leave the typesafe and cloudflare resource types to the hub instead of seeding them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * docs(AGENTS): resource types ship through the hub, not migrations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: show the questions JSON format beside the JSON toggle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: give the questions JSON format in the field's description Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: style the decision's graph branch button like Add branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: show the questions JSON format only in JSON mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: show each question's branching under the questions JSON editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: drop the redundant In JSON prefix from the questions format Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: shorten the decision state's description and show an example as its placeholder Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: escape a schema placeholder before the JSON editor renders it as HTML Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: give the decision state's placeholder a text and an object example Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * chore: update ee-repo-ref to 154cbccb6b8d47bf662b5507882917b5933d5a45 This commit updates the EE repository reference after PR #839 was merged in windmill-ee-private. Previous ee-repo-ref: eaf38c1388f3cc5bece163404fffa959ede8cae0 New ee-repo-ref: 154cbccb6b8d47bf662b5507882917b5933d5a45 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
ec0883a627
commit
3cba8a7e4c
@@ -90,6 +90,12 @@ are for a plain checkout with nothing running.
|
||||
- **Login**: `admin@windmill.dev` / `changeme`
|
||||
- **Instance settings**: navigate to `/#superadmin-settings`
|
||||
- **Migrations**: use `cargo sqlx migrate add -r <name>` from `backend/` to create new migrations (never generate timestamps manually)
|
||||
- **Resource types ship through the hub, not migrations.** A new type, a provider's credential
|
||||
included, is a `hub/<app>/<app>.resource_type.json` in `windmill-labs/windmill-integrations`
|
||||
and reaches instances by hub sync; a product name its identifier misses (`typesafe` →
|
||||
"TypeSafe") is that file's `display_name`, not a word in `resourceTypeDisplay.ts`'s map. Only a
|
||||
type Windmill itself defines and reads as a product object (`ai_skill`, `ai_instruction`) is
|
||||
also seeded by a migration, so the feature works before a sync. For local testing, insert the row.
|
||||
|
||||
### Per-worktree ports and database
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
259ad3bfeef5285ba80eedc86309b11dca001220
|
||||
154cbccb6b8d47bf662b5507882917b5933d5a45
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! AI decision steps run end to end against a stand-in for System One endpoints: TypeSafe's, and
|
||||
//! Cloudflare Workers AI's, which serves each model at its own URL inside an API envelope.
|
||||
|
||||
use axum::{routing::post, Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::{flows::FlowValue, jobs::JobPayload};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Answers every question as a choice of `refund`, the way TypeSafe shapes an answer.
|
||||
fn refund_answers(body: &Value) -> Value {
|
||||
let answers = body["questions"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(name, _)| {
|
||||
let answer = json!({
|
||||
"type": "choice",
|
||||
"choice": "refund",
|
||||
"probabilities": {"refund": 0.9, "bug": 0.1},
|
||||
"confidence": 0.8
|
||||
});
|
||||
(name.clone(), answer)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
json!({"model": body["model"], "answers": answers, "usage": {"input_tokens": 10}})
|
||||
}
|
||||
|
||||
async fn start_decision_stub() -> anyhow::Result<u16> {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/v1/systemone",
|
||||
post(|Json(body): Json<Value>| async move { Json(refund_answers(&body)) }),
|
||||
)
|
||||
.route(
|
||||
"/accounts/acc/ai/run/@cf/cloudflare/clef-flash",
|
||||
post(|Json(body): Json<Value>| async move {
|
||||
Json(json!({"result": refund_answers(&body), "success": true, "errors": []}))
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// A decision's answers reach later steps as `results.<id>.output`, which a Branch to one after it
|
||||
/// routes on.
|
||||
#[cfg(feature = "quickjs")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_ai_decision_answers_route_a_following_branch(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
// The stub listens on loopback, which the SSRF check refuses without this.
|
||||
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
|
||||
let stub_port = start_decision_stub().await?;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
for provider in [
|
||||
json!({
|
||||
"kind": "typesafe",
|
||||
"resource": {"api_key": "key", "base_url": format!("http://127.0.0.1:{stub_port}/v1")},
|
||||
"model": "jev-latest"
|
||||
}),
|
||||
json!({
|
||||
"kind": "cloudflare",
|
||||
"resource": {
|
||||
"token": "key",
|
||||
"base_url": format!("http://127.0.0.1:{stub_port}/accounts/acc/ai/run/@cf/cloudflare")
|
||||
},
|
||||
"model": "clef-flash"
|
||||
}),
|
||||
] {
|
||||
let flow: FlowValue = serde_json::from_value(json!({
|
||||
"modules": [
|
||||
{"id": "d", "value": {
|
||||
"type": "aidecision",
|
||||
"input_transforms": {
|
||||
"provider": {"type": "static", "value": provider},
|
||||
"state": {"type": "static", "value": "I was charged twice"},
|
||||
"questions": {"type": "static", "value": {"intent": {
|
||||
"type": "choice",
|
||||
"instructions": "What does the customer want?",
|
||||
"criteria": {"refund": "Money back", "bug": "Something is broken"}
|
||||
}}}
|
||||
}
|
||||
}},
|
||||
{"id": "route", "value": {
|
||||
"type": "branchone",
|
||||
"branches": [
|
||||
{
|
||||
"expr": "results.d.output.intent.choice === 'bug'",
|
||||
"modules": [{"id": "b", "value": {"type": "identity"}}]
|
||||
},
|
||||
{
|
||||
"expr": "results.d.output.intent.choice === 'refund'",
|
||||
"modules": [{"id": "r", "value": {
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main(choice){ return 'handled ' + choice }",
|
||||
"input_transforms": {"choice": {
|
||||
"type": "javascript",
|
||||
"expr": "results.d.output.intent.choice"
|
||||
}}
|
||||
}}]
|
||||
}
|
||||
],
|
||||
"default": []
|
||||
}}
|
||||
]
|
||||
}))?;
|
||||
|
||||
let job = run_job_in_new_worker_until_complete(
|
||||
&db,
|
||||
false,
|
||||
JobPayload::RawFlow { value: flow, path: None, restarted_from: None },
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
job.json_result(),
|
||||
Some(json!("handled refund")),
|
||||
"{provider}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -39,6 +39,7 @@ lazy_static::lazy_static! {
|
||||
pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
|
||||
pub const GOOGLE_AI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
|
||||
pub const TYPESAFE_BASE_URL: &str = "https://api.typesafe.ai/v1";
|
||||
|
||||
/// Hosts that serve the OpenAI API with Azure conventions: Azure OpenAI
|
||||
/// (`*.openai.azure.com`), AI Foundry (`*.services.ai.azure.com`,
|
||||
@@ -122,9 +123,35 @@ pub enum AIProvider {
|
||||
CustomAI,
|
||||
#[serde(rename = "aws_bedrock")]
|
||||
AWSBedrock,
|
||||
/// TypeSafe's Jev decision model. It answers typed questions rather than chatting, so only
|
||||
/// an AI decision runs it (`run_systemone`), never the agent loop.
|
||||
TypeSafe,
|
||||
/// Cloudflare Workers AI, for its Jev-compatible decision models (Clef). Decisions only, as
|
||||
/// for TypeSafe; its base URL comes from the resource's account id.
|
||||
Cloudflare,
|
||||
}
|
||||
|
||||
/// The Workers AI models URL of a Cloudflare account. The id goes into the path, so it is held to
|
||||
/// the characters an account id has rather than trusted to stay inside its segment.
|
||||
pub fn cloudflare_workers_ai_base_url(account_id: Option<&str>) -> Result<String> {
|
||||
match account_id {
|
||||
Some(id) if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()) => Ok(format!(
|
||||
"https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/cloudflare"
|
||||
)),
|
||||
Some(_) => Err(Error::BadRequest(
|
||||
"The Cloudflare account_id must be letters and digits only".to_string(),
|
||||
)),
|
||||
None => Err(Error::BadRequest(
|
||||
"A Cloudflare resource needs an account_id".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
impl AIProvider {
|
||||
/// A provider that answers decisions (typed questions) and serves no chat.
|
||||
pub fn is_decision_provider(&self) -> bool {
|
||||
matches!(self, AIProvider::TypeSafe | AIProvider::Cloudflare)
|
||||
}
|
||||
|
||||
/// Get the base URL for the AI provider
|
||||
pub async fn get_base_url(&self, resource_base_url: Option<String>, db: &DB) -> Result<String> {
|
||||
if let Some(base_url) = resource_base_url {
|
||||
@@ -177,6 +204,10 @@ impl AIProvider {
|
||||
AIProvider::TogetherAI => Ok("https://api.together.xyz/v1".to_string()),
|
||||
AIProvider::Anthropic => Ok("https://api.anthropic.com/v1".to_string()),
|
||||
AIProvider::Mistral => Ok("https://api.mistral.ai/v1".to_string()),
|
||||
AIProvider::TypeSafe => Ok(TYPESAFE_BASE_URL.to_string()),
|
||||
AIProvider::Cloudflare => Err(Error::BadRequest(
|
||||
"A Cloudflare resource needs an account_id".to_string(),
|
||||
)),
|
||||
p @ (AIProvider::CustomAI | AIProvider::AzureOpenAI | AIProvider::AzureFoundry) => {
|
||||
Err(Error::BadRequest(format!(
|
||||
"{:?} provider requires a base URL in the resource",
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! AI decision steps: one call to a System One endpoint (TypeSafe's Jev, or Cloudflare's
|
||||
//! Jev-compatible Clef on Workers AI), which answers typed questions (`choice`, `score`, `noul`)
|
||||
//! about a state with calibrated probabilities instead of text.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{value::RawValue, Map, Value};
|
||||
use windmill_common::error::{Error, Result};
|
||||
|
||||
use crate::{
|
||||
ai_providers::AIProvider,
|
||||
credentials::ProviderCredentials,
|
||||
proxy::{common_outbound_headers, retain_effective_credentials},
|
||||
types::{ProviderWithResource, TokenUsage},
|
||||
utils::pinned_ai_client_for,
|
||||
};
|
||||
|
||||
/// The inputs of an AI decision step or tool, as its `input_transforms` resolve them.
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct AIDecisionArgs {
|
||||
pub provider: ProviderWithResource,
|
||||
#[serde(default)]
|
||||
pub state: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub questions: Option<Value>,
|
||||
}
|
||||
|
||||
/// The alias TypeSafe keeps on its current model, sent when the step names none.
|
||||
pub const TYPESAFE_DEFAULT_MODEL: &str = "jev-latest";
|
||||
/// Cloudflare's larger decision model, run when the step names none.
|
||||
pub const CLOUDFLARE_DEFAULT_MODEL: &str = "clef";
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SystemOneRequest<'a> {
|
||||
model: &'a str,
|
||||
state: &'a Value,
|
||||
questions: &'a Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SystemOneResponse {
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
answers: Box<RawValue>,
|
||||
#[serde(default)]
|
||||
usage: Option<SystemOneUsage>,
|
||||
}
|
||||
|
||||
/// Workers AI wraps the System One response in its API envelope.
|
||||
#[derive(Deserialize)]
|
||||
struct CloudflareEnvelope {
|
||||
result: SystemOneResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SystemOneUsage {
|
||||
input_tokens: Option<i32>,
|
||||
output_tokens: Option<i32>,
|
||||
}
|
||||
|
||||
/// A decision's result. `output` holds the answers keyed by question name, where an agent's
|
||||
/// holds its answer, so a later step reads both as `results.<step>.output`.
|
||||
#[derive(Serialize)]
|
||||
pub struct DecisionResult {
|
||||
pub output: Box<RawValue>,
|
||||
/// The version that answered, which `jev-latest` resolves to.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<TokenUsage>,
|
||||
}
|
||||
|
||||
/// The state and questions of a decision, refused here rather than by the endpoint when
|
||||
/// either is missing or of a shape TypeSafe rejects: a flow that passes an empty expression result
|
||||
/// should read what it forgot, without a request going out.
|
||||
pub fn decision_inputs<'a>(
|
||||
state: Option<&'a Value>,
|
||||
questions: Option<&'a Value>,
|
||||
) -> Result<(&'a Value, &'a Map<String, Value>)> {
|
||||
let state = match state {
|
||||
None | Some(Value::Null) => {
|
||||
return Err(Error::BadRequest(
|
||||
"'state' must be provided for an AI decision".to_string(),
|
||||
))
|
||||
}
|
||||
Some(Value::String(s)) if s.trim().is_empty() => {
|
||||
return Err(Error::BadRequest(
|
||||
"'state' must be provided for an AI decision".to_string(),
|
||||
))
|
||||
}
|
||||
Some(state @ (Value::String(_) | Value::Object(_) | Value::Array(_))) => state,
|
||||
Some(_) => {
|
||||
return Err(Error::BadRequest(
|
||||
"'state' must be a text, an object or an array for an AI decision".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let questions = match questions {
|
||||
Some(Value::Object(questions)) if !questions.is_empty() => questions,
|
||||
_ => {
|
||||
return Err(Error::BadRequest(
|
||||
"'questions' must be an object naming at least one question for an AI decision"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok((state, questions))
|
||||
}
|
||||
|
||||
pub async fn run_systemone(
|
||||
credentials: &ProviderCredentials,
|
||||
model: &str,
|
||||
state: &Value,
|
||||
questions: &Map<String, Value>,
|
||||
timeout: Duration,
|
||||
) -> Result<DecisionResult> {
|
||||
let cloudflare = credentials.provider == AIProvider::Cloudflare;
|
||||
let model = match model.trim() {
|
||||
"" if cloudflare => CLOUDFLARE_DEFAULT_MODEL,
|
||||
"" => TYPESAFE_DEFAULT_MODEL,
|
||||
model => model,
|
||||
};
|
||||
let base = credentials.base_url.trim_end_matches('/');
|
||||
// Workers AI serves each model at its own URL; the model goes into the path, so it is held to
|
||||
// the characters a model id has.
|
||||
let endpoint = if cloudflare {
|
||||
if !model
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid Cloudflare decision model: {model}"
|
||||
)));
|
||||
}
|
||||
format!("{base}/{model}")
|
||||
} else {
|
||||
format!("{base}/systemone")
|
||||
};
|
||||
let vendor = if cloudflare { "Cloudflare" } else { "TypeSafe" };
|
||||
let auth_headers = retain_effective_credentials(
|
||||
credentials,
|
||||
vec![(
|
||||
"Authorization",
|
||||
format!(
|
||||
"Bearer {}",
|
||||
credentials.api_key.as_deref().unwrap_or_default()
|
||||
),
|
||||
)],
|
||||
);
|
||||
|
||||
// The endpoint derives from the resource's base URL, so the connect is pinned to the
|
||||
// address the SSRF check validated.
|
||||
let client = pinned_ai_client_for(&credentials.base_url).await?;
|
||||
let mut request = client.post(&endpoint).timeout(timeout);
|
||||
for (name, value) in auth_headers {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
for (name, value) in common_outbound_headers(credentials) {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.json(&SystemOneRequest { model, state, questions })
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to call {vendor}: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<failed to read body>".to_string());
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"{vendor} error calling {endpoint}: {status} - {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to read the {vendor} response: {e}")))?;
|
||||
// Tried in turn rather than as an untagged enum, which cannot hold the raw `answers`.
|
||||
let parsed = match serde_json::from_str::<SystemOneResponse>(&body) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(e) => match serde_json::from_str::<CloudflareEnvelope>(&body) {
|
||||
Ok(envelope) => envelope.result,
|
||||
Err(_) => {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"Unexpected {vendor} response ({e}): {body}"
|
||||
)))
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Ok(DecisionResult {
|
||||
output: parsed.answers,
|
||||
model: parsed.model,
|
||||
// Dropped when empty, as an agent's is, so `usage` is either counted or absent.
|
||||
usage: parsed
|
||||
.usage
|
||||
.map(|u| TokenUsage::from_input_output(u.input_tokens, u.output_tokens))
|
||||
.filter(|u| !u.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// The account id goes into the URL path, so anything that could leave its segment is refused.
|
||||
#[test]
|
||||
fn cloudflare_account_id_stays_in_its_segment() {
|
||||
use crate::ai_providers::cloudflare_workers_ai_base_url;
|
||||
assert_eq!(
|
||||
cloudflare_workers_ai_base_url(Some("abc123")).unwrap(),
|
||||
"https://api.cloudflare.com/client/v4/accounts/abc123/ai/run/@cf/cloudflare"
|
||||
);
|
||||
for id in [None, Some("a/b"), Some("../x"), Some("a?b"), Some("")] {
|
||||
assert!(cloudflare_workers_ai_base_url(id).is_err(), "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Refused are the values a flow produces when it forgot the input (no key, `null`, a blank
|
||||
/// string from an empty expression) and the scalars TypeSafe rejects. A text, an object or an
|
||||
/// array is a state, whatever it holds.
|
||||
#[test]
|
||||
fn decision_inputs_refuse_what_typesafe_would() {
|
||||
let questions = json!({"urgent": {"type": "noul", "instructions": "Is it urgent?"}});
|
||||
for state in [
|
||||
None,
|
||||
Some(json!(null)),
|
||||
Some(json!(" ")),
|
||||
Some(json!(false)),
|
||||
Some(json!(0)),
|
||||
] {
|
||||
let err = decision_inputs(state.as_ref(), Some(&questions)).unwrap_err();
|
||||
assert!(err.to_string().contains("'state'"), "{err}");
|
||||
}
|
||||
for state in [json!("0"), json!({}), json!([]), json!({"m": "hi"})] {
|
||||
assert!(
|
||||
decision_inputs(Some(&state), Some(&questions)).is_ok(),
|
||||
"{state}"
|
||||
);
|
||||
}
|
||||
for questions in [
|
||||
None,
|
||||
Some(json!({})),
|
||||
Some(json!([])),
|
||||
Some(json!("urgent")),
|
||||
] {
|
||||
let err = decision_inputs(Some(&json!("hi")), questions.as_ref()).unwrap_err();
|
||||
assert!(err.to_string().contains("'questions'"), "{err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod ai_google;
|
||||
pub mod ai_providers;
|
||||
pub mod ai_types;
|
||||
pub mod credentials;
|
||||
pub mod decision;
|
||||
pub mod image_handler;
|
||||
pub mod model_context;
|
||||
pub mod providers;
|
||||
|
||||
@@ -204,6 +204,12 @@ pub fn create_query_builder(
|
||||
AIProvider::AzureFoundry if AIProvider::is_anthropic_model(model) => Box::new(
|
||||
AnthropicQueryBuilder::new(AIProvider::AzureFoundry, AIPlatform::Standard),
|
||||
),
|
||||
// Only for forwarding through the API proxy, which is bearer auth on `<base>/<path>` as
|
||||
// TypeSafe and Cloudflare expect. Neither serves a chat route for its decision models, and
|
||||
// `run_agent` refuses both before a chat request could be built.
|
||||
AIProvider::TypeSafe | AIProvider::Cloudflare => {
|
||||
Box::new(OtherQueryBuilder::new(credentials.provider.clone()))
|
||||
}
|
||||
_ => Box::new(OtherQueryBuilder::new(credentials.provider.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ pub fn proxy_execution_mode(provider: &AIProvider) -> ProxyExecutionMode {
|
||||
| AIProvider::Groq
|
||||
| AIProvider::OpenRouter
|
||||
| AIProvider::TogetherAI
|
||||
| AIProvider::CustomAI => ProxyExecutionMode::HttpForward,
|
||||
| AIProvider::CustomAI
|
||||
| AIProvider::TypeSafe
|
||||
| AIProvider::Cloudflare => ProxyExecutionMode::HttpForward,
|
||||
AIProvider::GoogleAI => ProxyExecutionMode::NativeGoogleAi,
|
||||
AIProvider::AWSBedrock => ProxyExecutionMode::NativeAwsBedrock,
|
||||
}
|
||||
@@ -444,6 +446,8 @@ mod tests {
|
||||
(AIProvider::OpenRouter, ProxyExecutionMode::HttpForward),
|
||||
(AIProvider::TogetherAI, ProxyExecutionMode::HttpForward),
|
||||
(AIProvider::CustomAI, ProxyExecutionMode::HttpForward),
|
||||
(AIProvider::TypeSafe, ProxyExecutionMode::HttpForward),
|
||||
(AIProvider::Cloudflare, ProxyExecutionMode::HttpForward),
|
||||
(AIProvider::GoogleAI, ProxyExecutionMode::NativeGoogleAi),
|
||||
(AIProvider::AWSBedrock, ProxyExecutionMode::NativeAwsBedrock),
|
||||
];
|
||||
|
||||
@@ -269,6 +269,12 @@ pub struct ProviderResource {
|
||||
/// credentials exchange this crate does not perform.
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub token_url: Option<String>,
|
||||
/// The API token of a `cloudflare` resource, which names it `token` rather than `api_key`.
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub token: Option<String>,
|
||||
/// The account of a `cloudflare` resource, which its Workers AI URL is built from.
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -301,6 +307,11 @@ impl ProviderWithResource {
|
||||
}
|
||||
|
||||
pub async fn get_base_url(&self, db: &DB) -> Result<String, Error> {
|
||||
if self.kind == AIProvider::Cloudflare && self.resource.base_url.is_none() {
|
||||
return crate::ai_providers::cloudflare_workers_ai_base_url(
|
||||
self.resource.account_id.as_deref(),
|
||||
);
|
||||
}
|
||||
self.kind
|
||||
.get_base_url(self.resource.base_url.clone(), db)
|
||||
.await
|
||||
@@ -320,7 +331,14 @@ impl ProviderWithResource {
|
||||
Ok(ProviderCredentials {
|
||||
provider: self.kind.clone(),
|
||||
base_url,
|
||||
api_key: self.resource.api_key.clone(),
|
||||
api_key: match self.kind {
|
||||
AIProvider::Cloudflare => self
|
||||
.resource
|
||||
.api_key
|
||||
.clone()
|
||||
.or_else(|| self.resource.token.clone()),
|
||||
_ => self.resource.api_key.clone(),
|
||||
},
|
||||
access_token: None,
|
||||
organization_id: None,
|
||||
user: None,
|
||||
|
||||
@@ -28478,6 +28478,8 @@ components:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/AiTransform"
|
||||
ProviderConfig:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/ProviderConfig"
|
||||
ProviderTransform:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/ProviderTransform"
|
||||
MemoryConfig:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/MemoryConfig"
|
||||
FlowModuleValue:
|
||||
@@ -28498,6 +28500,8 @@ components:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/BranchAll"
|
||||
AiAgent:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/AiAgent"
|
||||
AiDecision:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/AiDecision"
|
||||
Identity:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/Identity"
|
||||
FlowStatus:
|
||||
@@ -29462,6 +29466,8 @@ components:
|
||||
togetherai,
|
||||
aws_bedrock,
|
||||
customai,
|
||||
typesafe,
|
||||
cloudflare,
|
||||
]
|
||||
|
||||
GitSyncObjectType:
|
||||
|
||||
@@ -246,6 +246,12 @@ struct AIStandardResource {
|
||||
/// Custom HTTP headers to include in AI requests
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
/// The API token of a `cloudflare` resource, which names it `token` rather than `api_key`.
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
token: Option<String>,
|
||||
/// The account of a `cloudflare` resource, which its Workers AI URL is built from.
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -293,10 +299,21 @@ async fn resolve_provider_credentials(
|
||||
// Skip get_base_url for Bedrock - it uses SDK directly, not HTTP
|
||||
let base_url = if matches!(provider, AIProvider::AWSBedrock) {
|
||||
String::new()
|
||||
} else if *provider == AIProvider::Cloudflare && resource.base_url.is_none() {
|
||||
let account_id = if let Some(account_id) = resource.account_id {
|
||||
Some(resolve_var(account_id, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
windmill_ai::ai_providers::cloudflare_workers_ai_base_url(account_id.as_deref())?
|
||||
} else {
|
||||
provider.get_base_url(resource.base_url, db).await?
|
||||
};
|
||||
let api_key = if let Some(api_key) = resource.api_key {
|
||||
let api_key = match provider {
|
||||
AIProvider::Cloudflare => resource.api_key.or(resource.token),
|
||||
_ => resource.api_key,
|
||||
};
|
||||
let api_key = if let Some(api_key) = api_key {
|
||||
Some(resolve_var(api_key, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -3332,6 +3332,7 @@ fn flow_tree_entry_label(
|
||||
"forloopflow" => " forloop",
|
||||
"whileloopflow" => " whileloop",
|
||||
"aiagent" => " ai-agent",
|
||||
"aidecision" => " ai-decision",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
|
||||
@@ -357,6 +357,8 @@ fn check_module_value_is_composition_only(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Runs no code: it sends its inputs to the decision model.
|
||||
FlowModuleValue::AIDecision { tag, .. } => push_tag(tag),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -377,6 +377,8 @@ impl FlowValue {
|
||||
| Flow { .. }
|
||||
| FlowScript { .. }
|
||||
| Identity) => cb(&s, &module.id)?,
|
||||
// Runs no script of its own.
|
||||
AIDecision { .. } => {}
|
||||
ForloopFlow { modules, .. } | WhileloopFlow { modules, .. } => {
|
||||
Self::traverse_leafs(modules.iter().collect(), cb)?
|
||||
}
|
||||
@@ -1138,6 +1140,13 @@ pub enum FlowModuleValue {
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
tool_inputs: HashMap<String, HashMap<String, InputTransform>>,
|
||||
},
|
||||
/// One call to a decision model (TypeSafe's Jev) answering the typed `questions` of its
|
||||
/// `input_transforms` about a `state`. A flow branches on the answers with a `BranchOne`.
|
||||
AIDecision {
|
||||
input_transforms: HashMap<String, InputTransform>,
|
||||
#[serde(skip_serializing_if = "is_none_or_empty")]
|
||||
tag: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn is_none_or_empty(expr: &Option<String>) -> bool {
|
||||
@@ -1282,6 +1291,10 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
|
||||
agent: untagged.agent,
|
||||
tool_inputs: untagged.tool_inputs.unwrap_or_default(),
|
||||
}),
|
||||
"aidecision" => Ok(FlowModuleValue::AIDecision {
|
||||
input_transforms: untagged.input_transforms.unwrap_or_default(),
|
||||
tag: untagged.tag,
|
||||
}),
|
||||
other => Err(serde::de::Error::unknown_variant(
|
||||
other,
|
||||
&[
|
||||
@@ -1294,6 +1307,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
|
||||
"rawscript",
|
||||
"identity",
|
||||
"aiagent",
|
||||
"aidecision",
|
||||
],
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -372,7 +372,8 @@ async fn enqueue_windmill_tool(
|
||||
FlowModuleValue::Script { input_transforms, .. }
|
||||
| FlowModuleValue::RawScript { input_transforms, .. }
|
||||
| FlowModuleValue::FlowScript { input_transforms, .. }
|
||||
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
|
||||
| FlowModuleValue::AIAgent { input_transforms, .. }
|
||||
| FlowModuleValue::AIDecision { input_transforms, .. } => input_transforms,
|
||||
_ => {
|
||||
return Err(Error::internal_err(format!(
|
||||
"Unsupported tool: {}",
|
||||
@@ -473,13 +474,15 @@ async fn enqueue_windmill_tool(
|
||||
let has_nested_agent_tools = sub_tools.iter().any(|t| {
|
||||
matches!(
|
||||
t.value,
|
||||
windmill_common::flows::ToolValue::FlowModule(FlowModuleValue::AIAgent { .. })
|
||||
windmill_common::flows::ToolValue::FlowModule(
|
||||
FlowModuleValue::AIAgent { .. } | FlowModuleValue::AIDecision { .. }
|
||||
)
|
||||
)
|
||||
});
|
||||
if has_nested_agent_tools {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent tools cannot be nested beyond 2 levels. The nested agent tool contains \
|
||||
AIAgent sub-tools, which would exceed the maximum nesting depth.".to_string()
|
||||
AI agent or AI decision sub-tools, which would exceed the maximum nesting depth.".to_string()
|
||||
));
|
||||
}
|
||||
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
|
||||
@@ -492,6 +495,18 @@ async fn enqueue_windmill_tool(
|
||||
on_behalf_of: None,
|
||||
}
|
||||
}
|
||||
// Runs as an AI agent job, whose handler finds this tool on the calling agent and answers
|
||||
// it as a decision.
|
||||
FlowModuleValue::AIDecision { tag, .. } => JobPayloadWithTag {
|
||||
payload: JobPayload::AIAgent {
|
||||
path: format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id),
|
||||
},
|
||||
tag: tag.filter(|t| !t.trim().is_empty()),
|
||||
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
on_behalf_of: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(Error::internal_err(format!(
|
||||
"Unsupported tool: {}",
|
||||
@@ -834,8 +849,12 @@ async fn execute_windmill_tools(
|
||||
.result
|
||||
.map(|value| value.0)
|
||||
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
|
||||
// A nested agent's or a decision's result is an envelope; the model sees `output`.
|
||||
let is_agent = tool.module.as_ref().is_some_and(|module| {
|
||||
matches!(module.get_value(), Ok(FlowModuleValue::AIAgent { .. }))
|
||||
matches!(
|
||||
module.get_value(),
|
||||
Ok(FlowModuleValue::AIAgent { .. } | FlowModuleValue::AIDecision { .. })
|
||||
)
|
||||
});
|
||||
let content = if is_agent && completed.success {
|
||||
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
|
||||
|
||||
@@ -720,6 +720,7 @@ pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
|
||||
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
|
||||
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
|
||||
FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
|
||||
FlowModuleValue::AIDecision { input_transforms, .. } => input_transforms,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ use windmill_mcp::McpClient;
|
||||
use crate::ai::tools::McpClientStub as McpClient;
|
||||
use windmill_ai::{
|
||||
ai_providers::AIProvider,
|
||||
decision::{decision_inputs, run_systemone, AIDecisionArgs},
|
||||
image_handler::upload_image_to_s3,
|
||||
model_context::model_context_window,
|
||||
providers::{
|
||||
@@ -67,7 +68,10 @@ use crate::{
|
||||
build_args_map, get_root_job_id, resolve_job_timeout, transform_json_value,
|
||||
OccupancyMetrics, StreamNotifier,
|
||||
},
|
||||
handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome},
|
||||
handle_child::{
|
||||
run_future_with_polling_update_job_poller,
|
||||
run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome,
|
||||
},
|
||||
};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -81,6 +85,20 @@ lazy_static::lazy_static! {
|
||||
"required": ["user_message"],
|
||||
"additionalProperties": false,
|
||||
}));
|
||||
|
||||
// Text only: the model writes the state as it reads the conversation, and a schema offering a
|
||||
// union of types is refused by some providers' strict tool schemas.
|
||||
static ref AI_DECISION_TOOL_SCHEMA: Box<RawValue> = to_raw_value(&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "What the questions are asked about, with only what they need.",
|
||||
},
|
||||
},
|
||||
"required": ["state"],
|
||||
"additionalProperties": false,
|
||||
}));
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
|
||||
@@ -498,7 +516,8 @@ fn overlay_tool_inputs(
|
||||
FlowModuleValue::Script { input_transforms, .. }
|
||||
| FlowModuleValue::RawScript { input_transforms, .. }
|
||||
| FlowModuleValue::FlowScript { input_transforms, .. }
|
||||
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
|
||||
| FlowModuleValue::AIAgent { input_transforms, .. }
|
||||
| FlowModuleValue::AIDecision { input_transforms, .. } => input_transforms,
|
||||
_ => continue,
|
||||
};
|
||||
for (key, transform) in overrides {
|
||||
@@ -713,6 +732,22 @@ pub async fn handle_ai_agent_job(
|
||||
|
||||
let summary = module.summary.clone();
|
||||
|
||||
let module_value = module.get_value()?;
|
||||
if let FlowModuleValue::AIDecision { .. } = module_value {
|
||||
return handle_ai_decision(
|
||||
conn,
|
||||
db,
|
||||
job,
|
||||
&local_args,
|
||||
direct_parent_job_kind == JobKind::AIAgent,
|
||||
canceled_by,
|
||||
mem_peak,
|
||||
occupancy_metrics,
|
||||
worker_name,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let FlowModuleValue::AIAgent {
|
||||
tools: module_tools,
|
||||
omit_output_from_conversation,
|
||||
@@ -720,7 +755,7 @@ pub async fn handle_ai_agent_job(
|
||||
tool_inputs,
|
||||
input_transforms: step_input_transforms,
|
||||
..
|
||||
} = module.get_value()?
|
||||
} = module_value
|
||||
else {
|
||||
return Err(Error::internal_err(
|
||||
"AI agent module is not an AI agent".to_string(),
|
||||
@@ -820,16 +855,18 @@ pub async fn handle_ai_agent_job(
|
||||
keep_authored_memory_id(&mut args, &step_input_transforms);
|
||||
|
||||
// Nesting is capped at flow → agent → nested agent. When this job is itself a nested tool,
|
||||
// a linked resource's tool set may still contain AIAgent tools (the editor can't constrain a
|
||||
// shared resource); don't advertise them — invoking one would only fail the depth check as a
|
||||
// third-level agent.
|
||||
// a linked resource's tool set may still contain AIAgent or AIDecision tools (the editor can't
|
||||
// constrain a shared resource); don't advertise them — invoking one would only fail the depth
|
||||
// check as a third level.
|
||||
let tools = if direct_parent_job_kind == JobKind::AIAgent {
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
!matches!(
|
||||
&t.value,
|
||||
ToolValue::FlowModule(FlowModuleValue::AIAgent { .. })
|
||||
ToolValue::FlowModule(
|
||||
FlowModuleValue::AIAgent { .. } | FlowModuleValue::AIDecision { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
@@ -1004,6 +1041,18 @@ pub async fn handle_ai_agent_job(
|
||||
None,
|
||||
)
|
||||
}
|
||||
FlowModuleValue::AIDecision { input_transforms, .. } => {
|
||||
// The calling model supplies only the state: the questions are the tool.
|
||||
let description = decision_tool_description(&input_transforms);
|
||||
(
|
||||
Some(
|
||||
RawValue::from_string(AI_DECISION_TOOL_SCHEMA.get().to_string())
|
||||
.expect("AI_DECISION_TOOL_SCHEMA should always be valid JSON"),
|
||||
),
|
||||
input_transforms,
|
||||
description,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::internal_err(format!(
|
||||
"Unsupported tool: {}",
|
||||
@@ -1242,6 +1291,12 @@ pub async fn run_agent(
|
||||
job_completed_tx: crate::JobCompletedSender,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text);
|
||||
if args.provider.kind.is_decision_provider() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{:?} serves decision models, which answer typed questions rather than messages: use an AI decision step",
|
||||
args.provider.kind
|
||||
)));
|
||||
}
|
||||
let credentials = args.provider.to_provider_credentials(db).await?;
|
||||
let base_url = &credentials.base_url;
|
||||
let api_key = credentials.api_key.as_deref().unwrap_or("");
|
||||
@@ -2379,6 +2434,104 @@ pub async fn run_agent(
|
||||
}))
|
||||
}
|
||||
|
||||
/// An AI decision, run as a flow step or as a tool of an agent: its questions answered about its
|
||||
/// state in one call, with no loop, tools, memory or stream. It writes no conversation row: it is
|
||||
/// not an agent step, so a chat flow posts its result like any other step's.
|
||||
async fn handle_ai_decision(
|
||||
conn: &Connection,
|
||||
db: &DB,
|
||||
job: &MiniPulledJob,
|
||||
local_args: &HashMap<String, Box<RawValue>>,
|
||||
as_tool: bool,
|
||||
canceled_by: &mut Option<CanceledBy>,
|
||||
mem_peak: &mut i32,
|
||||
occupancy_metrics: &mut OccupancyMetrics,
|
||||
worker_name: &str,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let args = serde_json::from_str::<AIDecisionArgs>(&serde_json::to_string(local_args)?)?;
|
||||
if !args.provider.kind.is_decision_provider() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"An AI decision runs on a TypeSafe or Cloudflare resource, not {:?}",
|
||||
args.provider.kind
|
||||
)));
|
||||
}
|
||||
let (state, questions) = decision_inputs(args.state.as_ref(), args.questions.as_ref())?;
|
||||
let credentials = args.provider.to_provider_credentials(db).await?;
|
||||
let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout)
|
||||
.await
|
||||
.0;
|
||||
// Under the job poller, so the job keeps its heartbeat and a cancel drops the request.
|
||||
let result = run_future_with_polling_update_job_poller(
|
||||
job.id,
|
||||
job.timeout,
|
||||
conn,
|
||||
mem_peak,
|
||||
canceled_by,
|
||||
run_systemone(
|
||||
&credentials,
|
||||
args.provider.get_model(),
|
||||
state,
|
||||
questions,
|
||||
timeout,
|
||||
),
|
||||
worker_name,
|
||||
&job.workspace_id,
|
||||
&mut Some(occupancy_metrics),
|
||||
Box::pin(futures::stream::once(async { 0 })),
|
||||
)
|
||||
.await?;
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"ai_decision",
|
||||
"run",
|
||||
if as_tool { "tool" } else { "step" },
|
||||
);
|
||||
Ok(to_raw_value(&result))
|
||||
}
|
||||
|
||||
/// What a decision tool tells the calling model it answers, read off its static questions. None
|
||||
/// when they come from an expression, which only a run evaluates.
|
||||
fn decision_tool_description(input_transforms: &HashMap<String, InputTransform>) -> Option<String> {
|
||||
let InputTransform::Static { value } = input_transforms.get("questions")? else {
|
||||
return None;
|
||||
};
|
||||
let questions = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(value.get())
|
||||
.ok()
|
||||
.filter(|questions| !questions.is_empty())?;
|
||||
let described = questions
|
||||
.iter()
|
||||
.map(|(name, question)| {
|
||||
let criteria = question.get("criteria");
|
||||
let kind = match question.get("type").and_then(|t| t.as_str()) {
|
||||
Some("choice") => {
|
||||
let options = criteria
|
||||
.and_then(|c| c.as_object())
|
||||
.map(|c| c.keys().cloned().collect::<Vec<_>>().join(", "))
|
||||
.unwrap_or_default();
|
||||
format!("one of {options}")
|
||||
}
|
||||
Some("score") => {
|
||||
let levels = criteria
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|c| {
|
||||
c.iter()
|
||||
.filter_map(|l| l.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" < ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("a score over {levels}")
|
||||
}
|
||||
_ => "yes or no".to_string(),
|
||||
};
|
||||
format!("{name} ({kind})")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
Some(format!(
|
||||
"Answers these questions about the state it is given, with probabilities: {described}."
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether the step asked for its answer as it is generated. Absence means on, matching the
|
||||
/// schema's own default: a step that never wrote the key never had an opinion, and an answer
|
||||
/// arriving as it is written is what people expect. Only an explicit `false` holds it back.
|
||||
|
||||
@@ -4184,7 +4184,8 @@ async fn push_next_flow_job(
|
||||
| FlowModuleValue::RawScript { input_transforms, .. }
|
||||
| FlowModuleValue::FlowScript { input_transforms, .. }
|
||||
| FlowModuleValue::Flow { input_transforms, .. }
|
||||
| FlowModuleValue::AIAgent { input_transforms, .. },
|
||||
| FlowModuleValue::AIAgent { input_transforms, .. }
|
||||
| FlowModuleValue::AIDecision { input_transforms, .. },
|
||||
) => {
|
||||
let ctx = get_transform_context(&flow_job, &previous_id, &status);
|
||||
transform_context = Some(ctx);
|
||||
@@ -5488,7 +5489,8 @@ async fn compute_next_flow_transform(
|
||||
NextStatus::NextStep,
|
||||
))
|
||||
}
|
||||
FlowModuleValue::AIAgent { tag, .. } => {
|
||||
// A decision runs as an AI agent job, whose handler answers it as a decision.
|
||||
FlowModuleValue::AIAgent { tag, .. } | FlowModuleValue::AIDecision { tag, .. } => {
|
||||
let path = get_path(flow_job, status, module);
|
||||
let payload = JobPayload::AIAgent { path };
|
||||
Ok(NextFlowTransform::Continue(
|
||||
|
||||
Generated
+86
-1
File diff suppressed because one or more lines are too long
@@ -4,8 +4,8 @@
|
||||
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
|
||||
without any identifying data leaving the instance.
|
||||
|
||||
It currently carries 61 registered actions across twenty features (`ai_session`, `ai_chat`,
|
||||
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`,
|
||||
It currently carries 62 registered actions across twenty-one features (`ai_session`, `ai_chat`,
|
||||
`ai_fix`, `ai_agent`, `ai_decision`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`,
|
||||
`flow_run`, `flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`,
|
||||
`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the
|
||||
product is uninstrumented, so new user-facing work is the opportunity to change that.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { type Snippet } from 'svelte'
|
||||
import { type Snippet, untrack } from 'svelte'
|
||||
import Select from './select/Select.svelte'
|
||||
import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib'
|
||||
import {
|
||||
fetchAvailableModels,
|
||||
AI_PROVIDERS,
|
||||
DECISION_AI_PROVIDERS,
|
||||
aiProviderDetails
|
||||
} from './copilot/lib'
|
||||
import type { AIProvider, ProviderConfig } from '$lib/gen'
|
||||
import ResourcePicker from './ResourcePicker.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
@@ -19,13 +24,17 @@
|
||||
* other than the one being navigated. Resources and the models read off them are per
|
||||
* workspace, so without it this offers what the wrong one holds. */
|
||||
workspace?: string | undefined
|
||||
/** Offer the decision providers (TypeSafe, Cloudflare) in place of the chat ones, for an AI
|
||||
* decision. */
|
||||
decision?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
value: _uncheckedValue = $bindable(),
|
||||
disabled = false,
|
||||
actions,
|
||||
workspace = undefined
|
||||
workspace = undefined,
|
||||
decision = false
|
||||
}: Props = $props()
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace ?? '')
|
||||
@@ -44,12 +53,15 @@
|
||||
let modelsCache = new Map<string, string[]>()
|
||||
|
||||
// The resource picker offers every provider type at once and the pick is what names the kind.
|
||||
// One string for the component's life: it is what the picker queries with.
|
||||
const providerResourceTypes = Object.keys(AI_PROVIDERS).join(',')
|
||||
// One list for the component's life: it is what the picker queries with.
|
||||
const offeredProviders = Object.keys(
|
||||
untrack(() => decision) ? DECISION_AI_PROVIDERS : AI_PROVIDERS
|
||||
)
|
||||
const providerResourceTypes = offeredProviders.join(',')
|
||||
|
||||
if (!_uncheckedValue) {
|
||||
_uncheckedValue = {
|
||||
kind: 'openai',
|
||||
kind: untrack(() => decision) ? 'typesafe' : 'openai',
|
||||
resource: '',
|
||||
model: ''
|
||||
}
|
||||
@@ -101,7 +113,7 @@
|
||||
return
|
||||
}
|
||||
// Fall back to default models for this provider
|
||||
const defaultModels = AI_PROVIDERS[provider]?.defaultModels || []
|
||||
const defaultModels = aiProviderDetails(provider).defaultModels
|
||||
availableModels = defaultModels
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
@@ -118,7 +130,7 @@
|
||||
function onResourcePicked(_path: string | undefined, type: string | undefined) {
|
||||
// An empty type is the placeholder the picker keeps for a saved path it could not find. It
|
||||
// says nothing about the provider, so the kind stands.
|
||||
if (!value || !type || !(type in AI_PROVIDERS)) {
|
||||
if (!value || !type || !offeredProviders.includes(type)) {
|
||||
return
|
||||
}
|
||||
if (value.kind === type) {
|
||||
@@ -166,7 +178,7 @@
|
||||
if (provider && resourcePath) {
|
||||
loadModels(abortController.signal)
|
||||
} else {
|
||||
const defaultModels = provider ? AI_PROVIDERS[provider]?.defaultModels || [] : []
|
||||
const defaultModels = provider ? aiProviderDetails(provider).defaultModels : []
|
||||
availableModels = defaultModels
|
||||
loading = false
|
||||
}
|
||||
@@ -226,7 +238,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if value?.model}
|
||||
{#if value?.model && !decision}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-normal text-secondary">Reasoning effort</span>
|
||||
<AIReasoningEffortPicker
|
||||
@@ -237,21 +249,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Toggle
|
||||
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
|
||||
bind:checked={useAsDefault}
|
||||
options={{ right: 'Use as personal default for other new agents' }}
|
||||
size="xs"
|
||||
on:change={(e) => {
|
||||
if (!e.detail) {
|
||||
removeConfig()
|
||||
} else {
|
||||
saveConfig(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<!-- The default seeds new agents, which a decision provider cannot run. -->
|
||||
{#if !decision}
|
||||
<div class="flex justify-end">
|
||||
<Toggle
|
||||
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
|
||||
bind:checked={useAsDefault}
|
||||
options={{ right: 'Use as personal default for other new agents' }}
|
||||
size="xs"
|
||||
on:change={(e) => {
|
||||
if (!e.detail) {
|
||||
removeConfig()
|
||||
} else {
|
||||
saveConfig(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{@render actions?.()}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { Plus, Trash2, X } from 'lucide-svelte'
|
||||
import { Button } from './common'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import {
|
||||
branchableQuestions,
|
||||
emptyQuestion,
|
||||
optionNameError,
|
||||
questionNameError,
|
||||
questionsFitRows,
|
||||
questionsToRows,
|
||||
rowsToQuestions,
|
||||
type DecisionQuestionType,
|
||||
type QuestionRow
|
||||
} from './flows/aiDecisionQuestions'
|
||||
import { getAiDecisionStep } from './flows/aiDecisionBranching'
|
||||
import AiDecisionQuestionRouting from './flows/content/AiDecisionQuestionRouting.svelte'
|
||||
|
||||
interface Props {
|
||||
value: any
|
||||
disabled?: boolean
|
||||
onModeChange?: (mode: 'form' | 'json') => void
|
||||
}
|
||||
|
||||
let { value = $bindable(), disabled = false, onModeChange }: Props = $props()
|
||||
|
||||
const decisionStep = getAiDecisionStep()
|
||||
|
||||
// Whether the cards can hold the value: TypeSafe also takes structured instructions and
|
||||
// descriptions, which the cards would flatten to text, so such questions stay in JSON.
|
||||
let fitsRows = $state(questionsFitRows(untrack(() => value)))
|
||||
let mode: 'form' | 'json' = $state(untrack(() => fitsRows) ? 'form' : 'json')
|
||||
$effect(() => onModeChange?.(mode))
|
||||
let jsonCode = $state(
|
||||
JSON.stringify(
|
||||
untrack(() => value),
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
// The code editor reads `code` only when it is created, so a replaced value recreates it.
|
||||
let jsonEditorKey = $state(0)
|
||||
|
||||
let rows: QuestionRow[] = $state(questionsToRows(untrack(() => value)))
|
||||
// What the rows last wrote or were read from. Rows hold drafts the value leaves out (an unnamed
|
||||
// question), so only an edit writes; opening the editor leaves the stored value untouched.
|
||||
let synced = JSON.stringify(rowsToQuestions(untrack(() => rows)))
|
||||
let written = JSON.stringify(untrack(() => value) ?? {})
|
||||
|
||||
function readRows() {
|
||||
rows = questionsToRows(value)
|
||||
synced = JSON.stringify(rowsToQuestions(rows))
|
||||
}
|
||||
|
||||
function readJson() {
|
||||
jsonCode = JSON.stringify(value, null, 2)
|
||||
jsonEditorKey++
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const questions = rowsToQuestions(rows)
|
||||
const json = JSON.stringify(questions)
|
||||
untrack(() => {
|
||||
if (mode === 'form' && json !== synced) {
|
||||
synced = json
|
||||
written = json
|
||||
value = questions
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// A value replaced from elsewhere (an undo, the AI chat) is read into both views, and moves the
|
||||
// editor to JSON when the cards cannot hold it.
|
||||
$effect(() => {
|
||||
const json = JSON.stringify(value ?? {})
|
||||
untrack(() => {
|
||||
if (json !== written) {
|
||||
written = json
|
||||
fitsRows = questionsFitRows(value)
|
||||
if (!fitsRows) mode = 'json'
|
||||
readJson()
|
||||
readRows()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function switchTo(next: 'form' | 'json') {
|
||||
if (next === mode || (next === 'form' && !fitsRows)) return
|
||||
if (next === 'form') readRows()
|
||||
else readJson()
|
||||
mode = next
|
||||
}
|
||||
|
||||
function addQuestion() {
|
||||
let i = rows.length + 1
|
||||
while (rows.some((r) => r.name === `question_${i}`)) i++
|
||||
rows.push(emptyQuestion(`question_${i}`))
|
||||
}
|
||||
|
||||
const TYPES: { value: DecisionQuestionType; label: string }[] = [
|
||||
{ value: 'choice', label: 'Choice' },
|
||||
{ value: 'score', label: 'Score' },
|
||||
{ value: 'noul', label: 'Yes/no' }
|
||||
]
|
||||
</script>
|
||||
|
||||
{#snippet label(text: string)}
|
||||
<span class="text-xs font-semibold text-emphasis">{text}</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet error(text: string | undefined)}
|
||||
{#if text}
|
||||
<span class="text-2xs text-red-600 dark:text-red-400">{text}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<div class="flex justify-end">
|
||||
<Toggle
|
||||
size="xs"
|
||||
lightMode
|
||||
disabled={disabled || !fitsRows}
|
||||
checked={mode === 'json'}
|
||||
options={{
|
||||
right: 'JSON',
|
||||
rightTooltip: fitsRows
|
||||
? 'Edit the questions as JSON'
|
||||
: 'These questions use structured instructions or descriptions, which only JSON can show'
|
||||
}}
|
||||
on:change={(e) => switchTo(e.detail ? 'json' : 'form')}
|
||||
/>
|
||||
</div>
|
||||
{#if mode === 'json'}
|
||||
<div class="w-full">
|
||||
{#key jsonEditorKey}
|
||||
{#await import('./JsonEditor.svelte') then Module}
|
||||
<Module.default
|
||||
bind:code={jsonCode}
|
||||
{disabled}
|
||||
on:changeValue={(e) => {
|
||||
written = JSON.stringify(e.detail ?? {})
|
||||
fitsRows = questionsFitRows(e.detail)
|
||||
value = e.detail
|
||||
}}
|
||||
/>
|
||||
{/await}
|
||||
{/key}
|
||||
</div>
|
||||
{#if decisionStep?.id}
|
||||
{@const id = decisionStep.id}
|
||||
{#each branchableQuestions(value) as q (q.name)}
|
||||
<AiDecisionQuestionRouting decisionId={id} question={q.name} named />
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
{#each rows as row, i (i)}
|
||||
<div class="flex flex-col gap-3 rounded-md bg-surface-tertiary p-3 shadow-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex flex-col gap-1 grow">
|
||||
{@render label('Name')}
|
||||
<TextInput
|
||||
size="sm"
|
||||
bind:value={row.name}
|
||||
error={questionNameError(rows, i)}
|
||||
inputProps={{ disabled, placeholder: 'intent' }}
|
||||
/>
|
||||
{@render error(questionNameError(rows, i))}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{@render label('Answer')}
|
||||
<ToggleButtonGroup
|
||||
noWFull
|
||||
{disabled}
|
||||
selected={row.type}
|
||||
onSelected={(v) => (row.type = v as DecisionQuestionType)}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
{#each TYPES as t (t.value)}
|
||||
<ToggleButton value={t.value} label={t.label} {item} small />
|
||||
{/each}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<div class="pt-5">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
destructive
|
||||
iconOnly
|
||||
{disabled}
|
||||
startIcon={{ icon: Trash2 }}
|
||||
title="Delete question"
|
||||
onClick={() => rows.splice(i, 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{@render label('Question')}
|
||||
<TextInput
|
||||
size="sm"
|
||||
underlyingInputEl="textarea"
|
||||
bind:value={row.instructions}
|
||||
autosizeParams={{ minHeight: 0 }}
|
||||
inputProps={{ disabled, placeholder: 'What does the customer want?', rows: 1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if row.type === 'choice'}
|
||||
<div class="flex flex-col gap-1">
|
||||
{@render label('Options')}
|
||||
<span class="text-xs text-secondary"
|
||||
>What each option means helps the model choose</span
|
||||
>
|
||||
{#each row.options as option, j (j)}
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="w-1/3 shrink-0 flex flex-col gap-1">
|
||||
<TextInput
|
||||
size="sm"
|
||||
bind:value={option.name}
|
||||
error={optionNameError(row, j)}
|
||||
inputProps={{ disabled, placeholder: 'refund' }}
|
||||
/>
|
||||
{@render error(optionNameError(row, j))}
|
||||
</div>
|
||||
<TextInput
|
||||
size="sm"
|
||||
bind:value={option.description}
|
||||
inputProps={{ disabled, placeholder: 'What this option means' }}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
disabled={disabled || row.options.length <= 2}
|
||||
startIcon={{ icon: X }}
|
||||
title="Remove option"
|
||||
onClick={() => row.options.splice(j, 1)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
wrapperClasses="self-start"
|
||||
{disabled}
|
||||
startIcon={{ icon: Plus }}
|
||||
onClick={() => row.options.push({ name: '', description: '' })}
|
||||
>
|
||||
Add option
|
||||
</Button>
|
||||
</div>
|
||||
{:else if row.type === 'score'}
|
||||
<div class="flex flex-col gap-1">
|
||||
{@render label('Levels')}
|
||||
<span class="text-xs text-secondary">Lowest first</span>
|
||||
{#each row.levels as _, j (j)}
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-xs text-secondary w-4 text-right">{j}</span>
|
||||
<TextInput
|
||||
size="sm"
|
||||
bind:value={row.levels[j]}
|
||||
inputProps={{ disabled, placeholder: j === 0 ? 'Can wait' : 'Right now' }}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
disabled={disabled || row.levels.length <= 2}
|
||||
startIcon={{ icon: X }}
|
||||
title="Remove level"
|
||||
onClick={() => row.levels.splice(j, 1)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
wrapperClasses="self-start"
|
||||
disabled={disabled || row.levels.length >= 10}
|
||||
startIcon={{ icon: Plus }}
|
||||
onClick={() => row.levels.push('')}
|
||||
>
|
||||
Add level
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
{@render label('Yes means')}
|
||||
<TextInput size="sm" bind:value={row.yes} inputProps={{ disabled }} />
|
||||
<span class="text-2xs text-hint">Optional</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{@render label('No means')}
|
||||
<TextInput size="sm" bind:value={row.no} inputProps={{ disabled }} />
|
||||
<span class="text-2xs text-hint">Optional</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if decisionStep?.id && row.type !== 'score' && row.name.trim()}
|
||||
<AiDecisionQuestionRouting decisionId={decisionStep.id} question={row.name.trim()} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
wrapperClasses="self-start"
|
||||
{disabled}
|
||||
startIcon={{ icon: Plus }}
|
||||
onClick={addQuestion}
|
||||
>
|
||||
Add question
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -8,6 +8,7 @@
|
||||
debounce,
|
||||
emptySchema,
|
||||
emptyString,
|
||||
escapeHtml,
|
||||
getSchemaFromProperties,
|
||||
type DynamicInput as DynamicInputTypes
|
||||
} from '$lib/utils'
|
||||
@@ -43,6 +44,7 @@
|
||||
import { base } from '$lib/base'
|
||||
import { getJsonSchemaFromResource } from './schema/jsonSchemaResource.svelte'
|
||||
import AIProviderPicker from './AIProviderPicker.svelte'
|
||||
import AiDecisionQuestionsEditor from './AiDecisionQuestionsEditor.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import FileInput from './common/fileInput/FileInput.svelte'
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
@@ -257,6 +259,7 @@
|
||||
// never reaches `value`, so without this the field would stay valid on its last parsed value.
|
||||
let jsonError: string = $state('')
|
||||
let isListJson = $state(false)
|
||||
let questionsInJson = $state(false)
|
||||
let hasIsListJsonChanged = $state(false)
|
||||
|
||||
let el: HTMLTextAreaElement | undefined = $state(undefined)
|
||||
@@ -698,7 +701,9 @@
|
||||
|
||||
{#if description}
|
||||
<div class={twMerge('text-xs text-secondary', css?.description?.class)}>
|
||||
<pre class="font-main whitespace-normal">{description}</pre>
|
||||
<pre class="font-main whitespace-normal"
|
||||
>{description}{#if inputCat == 'ai-decision-questions' && questionsInJson}{': { <name>: { type: choice | score | noul, instructions, criteria } }'}{/if}</pre
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1382,6 +1387,7 @@
|
||||
dispatch('blur')
|
||||
}}
|
||||
code={rawValue}
|
||||
placeholder={placeholder && escapeHtml(placeholder)}
|
||||
bind:error={jsonError}
|
||||
on:changeValue={(e) => {
|
||||
setNewValueFromCode(e.detail)
|
||||
@@ -1484,7 +1490,19 @@
|
||||
{showSchemaExplorer}
|
||||
/>
|
||||
{:else if inputCat == 'ai-provider'}
|
||||
<AIProviderPicker bind:value {disabled} {actions} {workspace} />
|
||||
<AIProviderPicker
|
||||
bind:value
|
||||
{disabled}
|
||||
{actions}
|
||||
{workspace}
|
||||
decision={format === 'ai-decision-provider'}
|
||||
/>
|
||||
{:else if inputCat == 'ai-decision-questions'}
|
||||
<AiDecisionQuestionsEditor
|
||||
bind:value
|
||||
{disabled}
|
||||
onModeChange={(mode) => (questionsInJson = mode === 'json')}
|
||||
/>
|
||||
{:else if inputCat == 'email'}
|
||||
<input
|
||||
{autofocus}
|
||||
|
||||
@@ -216,6 +216,11 @@
|
||||
<h3 class="mb-1 font-semibold mt-2 text-xs text-emphasis">Step inputs</h3>
|
||||
<InputTransformsViewer inputTransforms={stepDetail?.value?.input_transforms ?? {}} />
|
||||
</div>
|
||||
{:else if stepDetail.value.type == 'aidecision'}
|
||||
<div class="text-xs">
|
||||
<h3 class="mb-1 font-semibold mt-2 text-xs text-emphasis">Step inputs</h3>
|
||||
<InputTransformsViewer inputTransforms={stepDetail.value.input_transforms ?? {}} />
|
||||
</div>
|
||||
{:else if stepDetail.value.type == 'forloopflow'}
|
||||
<div>
|
||||
<p class="font-medium text-secondary pb-2"> Iterator expression: </p>
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
return 'Workspace script'
|
||||
case 'aiagent':
|
||||
return 'AI Agent'
|
||||
case 'aidecision':
|
||||
return 'AI decision'
|
||||
default:
|
||||
return stepDetail.id
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
class?: string | undefined
|
||||
disabled?: boolean
|
||||
fixedOverflowWidgets?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -30,7 +31,8 @@
|
||||
loadAsync = false,
|
||||
class: clazz = undefined,
|
||||
disabled = false,
|
||||
fixedOverflowWidgets = true
|
||||
fixedOverflowWidgets = true,
|
||||
placeholder = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let tooBig = $derived(code && code?.length > 1000000)
|
||||
@@ -101,6 +103,7 @@
|
||||
class={clazz}
|
||||
{disabled}
|
||||
{fixedOverflowWidgets}
|
||||
{placeholder}
|
||||
renderLineHighlight="none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { setAiDecisionStep } from './flows/aiDecisionBranching'
|
||||
import type { Schema } from '$lib/common'
|
||||
import { type FlowModule, type Job } from '$lib/gen'
|
||||
import { CornerDownLeft, Loader2 } from 'lucide-svelte'
|
||||
@@ -43,6 +44,9 @@
|
||||
runInputKeys = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// The test form takes sample questions, which are not the step's to branch on.
|
||||
setAiDecisionStep({ id: undefined })
|
||||
|
||||
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
let moduleTest: ModuleTest | undefined = $state()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
ScriptService,
|
||||
type AiAgent,
|
||||
type AiDecision,
|
||||
type FlowModule,
|
||||
type InputTransform,
|
||||
type Job
|
||||
@@ -214,6 +215,28 @@
|
||||
callbacks,
|
||||
previewBase
|
||||
)
|
||||
} else if (val.type == 'aidecision') {
|
||||
const { schema } = await loadSchemaFromModule(mod, opWs)
|
||||
const inputTransforms = Object.fromEntries(
|
||||
Object.keys(args).map((key) => [key, { expr: `flow_input.${key}`, type: 'javascript' }])
|
||||
) as AiDecision['input_transforms']
|
||||
await jobLoader?.runFlowPreview(
|
||||
args,
|
||||
{
|
||||
value: {
|
||||
modules: [
|
||||
{
|
||||
id: mod.id,
|
||||
value: { type: 'aidecision', input_transforms: inputTransforms, tag: val.tag }
|
||||
}
|
||||
]
|
||||
},
|
||||
summary: '',
|
||||
schema
|
||||
},
|
||||
callbacks,
|
||||
previewBase
|
||||
)
|
||||
} else {
|
||||
throw Error('Not supported module type')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { ResourceService, WorkspaceService, type AIConfig, type AIProvider } from '$lib/gen'
|
||||
import { AI_PROVIDERS, fetchAvailableModels } from '../../lib'
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
DECISION_AI_PROVIDERS,
|
||||
aiProviderDetails,
|
||||
fetchAvailableModels
|
||||
} from '../../lib'
|
||||
import {
|
||||
collectAiAgentProviderRefs,
|
||||
isModelId,
|
||||
@@ -10,8 +15,11 @@ import {
|
||||
} from './aiAgentProviders'
|
||||
|
||||
// Read lazily: evaluating this at module scope makes `AI_PROVIDERS` a load-time requirement
|
||||
// for everything that reaches this module, the whole global chat included.
|
||||
const aiResourceTypes = () => Object.keys(AI_PROVIDERS) as AIProvider[]
|
||||
// for everything that reaches this module, the whole global chat included. Decision kinds are
|
||||
// included so an AI decision step's resource validates; `validateAiAgentProviders` keeps each kind
|
||||
// to its step type.
|
||||
const aiResourceTypes = () =>
|
||||
[...Object.keys(AI_PROVIDERS), ...Object.keys(DECISION_AI_PROVIDERS)] as AIProvider[]
|
||||
|
||||
/** Kinds whose model listing `fetchAvailableModels` narrows before returning it: OpenAI and Azure
|
||||
* OpenAI keep only `gpt-`/`o`/`codex` ids (so a fine-tune never appears), Bedrock keeps text
|
||||
@@ -27,8 +35,14 @@ const FILTERED_MODEL_LISTING_KINDS: ReadonlySet<string> = new Set([
|
||||
/** Kinds whose endpoint serves ids its listing does not contain, so an unlisted id is never
|
||||
* grounds for calling it wrong. OpenRouter appends routing suffixes — `:online`, `:nitro`,
|
||||
* `:floor` — to any listed model without listing the combinations, and it carries no `base_url`
|
||||
* of its own (the backend supplies it), so `customEndpoint` does not cover it. */
|
||||
const ALIASING_MODEL_LISTING_KINDS: ReadonlySet<string> = new Set(['openrouter'])
|
||||
* of its own (the backend supplies it), so `customEndpoint` does not cover it. The decision
|
||||
* providers' lists are the ids Windmill knows, not listings: TypeSafe also serves `jev-preview` and
|
||||
* every pinned version. */
|
||||
const ALIASING_MODEL_LISTING_KINDS: ReadonlySet<string> = new Set([
|
||||
'openrouter',
|
||||
'typesafe',
|
||||
'cloudflare'
|
||||
])
|
||||
|
||||
/** Each resource costs one model listing call against the provider. A workspace with a
|
||||
* long tail of AI resources would otherwise stall every flow write. */
|
||||
@@ -260,8 +274,6 @@ async function loadOption(
|
||||
// Without a live listing, a model id cannot be ruled out: offer the configured
|
||||
// models (then the curated defaults) as a hint, but leave the check off.
|
||||
const fallback =
|
||||
configuredModels.length > 0
|
||||
? configuredModels
|
||||
: (AI_PROVIDERS[candidate.kind]?.defaultModels ?? [])
|
||||
configuredModels.length > 0 ? configuredModels : aiProviderDetails(candidate.kind).defaultModels
|
||||
return { ...base, models: sanitizeModelListing(fallback, false), modelsAreLive: false }
|
||||
}
|
||||
|
||||
@@ -67,7 +67,13 @@ describe('validateAiAgentProviders', () => {
|
||||
expect(() =>
|
||||
validateAiAgentProviders(anthropicProvider('claude-something-new'), {
|
||||
...COMPLETE,
|
||||
options: [{ ...ANTHROPIC, modelsAreLive: false, models: { ids: ['claude-sonnet-5'], complete: false } }]
|
||||
options: [
|
||||
{
|
||||
...ANTHROPIC,
|
||||
modelsAreLive: false,
|
||||
models: { ids: ['claude-sonnet-5'], complete: false }
|
||||
}
|
||||
]
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
@@ -107,6 +113,35 @@ describe('validateAiAgentProviders', () => {
|
||||
).not.toThrow()
|
||||
expect(warnings).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps decision providers to AI decision steps', () => {
|
||||
const typesafe = {
|
||||
type: 'static',
|
||||
value: { kind: 'typesafe', resource: '$res:u/admin/typesafe', model: 'jev-latest' }
|
||||
}
|
||||
const anthropic = {
|
||||
type: 'static',
|
||||
value: { kind: 'anthropic', resource: '$res:u/admin/anthropic', model: 'claude-sonnet-5' }
|
||||
}
|
||||
const decision = (provider: unknown) => [
|
||||
{ id: 'd', value: { type: 'aidecision', input_transforms: { provider } } }
|
||||
]
|
||||
|
||||
expect(() => validateAiAgentProviders(decision(anthropic), undefined)).toThrow(
|
||||
/Step "d".*must be "typesafe" or "cloudflare"/
|
||||
)
|
||||
expect(() => validateAiAgentProviders(agentStep(typesafe), undefined)).toThrow(
|
||||
/Step "agent".*use an "aidecision" step/
|
||||
)
|
||||
const cloudflare = {
|
||||
type: 'static',
|
||||
value: { kind: 'cloudflare', resource: '$res:u/admin/cloudflare', model: 'clef' }
|
||||
}
|
||||
expect(() => validateAiAgentProviders(decision(cloudflare), undefined)).not.toThrow()
|
||||
expect(() => validateAiAgentProviders(agentStep(cloudflare), undefined)).toThrow(
|
||||
/Step "agent".*use an "aidecision" step/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAiAgentProvidersPrompt', () => {
|
||||
@@ -120,10 +155,12 @@ describe('formatAiAgentProvidersPrompt', () => {
|
||||
expect(
|
||||
formatAiAgentProvidersPrompt({ ...COMPLETE, defaultModel }, { canAskUser: true })
|
||||
).toContain('the workspace default')
|
||||
expect(formatAiAgentProvidersPrompt({ ...AMBIGUOUS, defaultModel }, { canAskUser: true })).toContain(
|
||||
expect(
|
||||
formatAiAgentProvidersPrompt({ ...AMBIGUOUS, defaultModel }, { canAskUser: true })
|
||||
).toContain('askUserQuestion')
|
||||
expect(formatAiAgentProvidersPrompt(COMPLETE, { canAskUser: true })).toContain(
|
||||
'askUserQuestion'
|
||||
)
|
||||
expect(formatAiAgentProvidersPrompt(COMPLETE, { canAskUser: true })).toContain('askUserQuestion')
|
||||
})
|
||||
|
||||
it('never names askUserQuestion for a chat that does not have the tool', () => {
|
||||
@@ -135,9 +172,10 @@ describe('formatAiAgentProvidersPrompt', () => {
|
||||
|
||||
describe('collectAiAgentProviderRefs', () => {
|
||||
it('needs the catalog only for a step that states its own static provider', () => {
|
||||
expect(
|
||||
collectAiAgentProviderRefs(anthropicProvider('claude-sonnet-5'))
|
||||
).toEqual({ needsCatalog: true, resourceRefs: ['$res:u/admin/anthropic'] })
|
||||
expect(collectAiAgentProviderRefs(anthropicProvider('claude-sonnet-5'))).toEqual({
|
||||
needsCatalog: true,
|
||||
resourceRefs: ['$res:u/admin/anthropic']
|
||||
})
|
||||
expect(
|
||||
collectAiAgentProviderRefs([{ id: 'a', value: { type: 'aiagent', agent: 'u/admin/saved' } }])
|
||||
).toEqual({ needsCatalog: false, resourceRefs: [] })
|
||||
@@ -159,7 +197,10 @@ describe('an authoritative empty catalog', () => {
|
||||
)
|
||||
// Not knowing the resources is not the same as knowing there are none.
|
||||
expect(
|
||||
formatAiAgentProvidersPrompt({ options: [], resourcesAreComplete: false }, { canAskUser: true })
|
||||
formatAiAgentProvidersPrompt(
|
||||
{ options: [], resourcesAreComplete: false },
|
||||
{ canAskUser: true }
|
||||
)
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ export type AiAgentProviderCatalog = {
|
||||
defaultModel?: { kind: AIProvider; model: string }
|
||||
}
|
||||
|
||||
/** The providers an AI decision step runs on, and the kinds an AI agent step cannot. */
|
||||
const DECISION_PROVIDER_KINDS: readonly string[] = ['typesafe', 'cloudflare']
|
||||
|
||||
/** A model id as every provider writes one: `claude-sonnet-5`, `meta-llama/Llama-3.3-70B`,
|
||||
* `anthropic.claude-haiku-4-5-20251001-v1:0`, `ft:gpt-4o:acme::abc`. Anything else is not
|
||||
* rendered: a resource may point at a gateway someone else controls, and its listing lands in a
|
||||
@@ -146,11 +149,14 @@ This workspace has none, so an AI agent step has no model to run on. ${
|
||||
const truncationLine = catalog.resourcesAreComplete
|
||||
? ''
|
||||
: '\nThis list is incomplete: the workspace has AI provider resources that are not shown.'
|
||||
const decisionLine = catalog.options.some((o) => DECISION_PROVIDER_KINDS.includes(o.kind))
|
||||
? `\nA \`typesafe\` or \`cloudflare\` resource serves AI decision steps only, and AI decision steps run on nothing else.`
|
||||
: ''
|
||||
return `## AI provider resources in this workspace
|
||||
|
||||
An AI agent step's \`model\` must be one of the ids listed below for the resource it references — never a model id from memory, which the endpoint would reject at run time.
|
||||
|
||||
${catalog.options.map(describeOption).join('\n')}${truncationLine}
|
||||
${catalog.options.map(describeOption).join('\n')}${truncationLine}${decisionLine}
|
||||
${choiceLine}`
|
||||
}
|
||||
|
||||
@@ -176,7 +182,9 @@ export function selectAiAgentProviderCandidates(
|
||||
if (!configuredPaths.has(candidate.resourcePath)) return 2
|
||||
return candidate.kind === defaultProviderKind ? 0 : 1
|
||||
}
|
||||
return candidates.sort((a, b) => rank(a) - rank(b) || a.resourcePath.localeCompare(b.resourcePath))
|
||||
return candidates.sort(
|
||||
(a, b) => rank(a) - rank(b) || a.resourcePath.localeCompare(b.resourcePath)
|
||||
)
|
||||
}
|
||||
|
||||
/** What a set of modules needs from the catalog: `needsCatalog` is false when no AI agent step
|
||||
@@ -224,7 +232,8 @@ function blocking(message: string): ProviderIssue {
|
||||
|
||||
function checkProviderValue(
|
||||
value: unknown,
|
||||
catalog: AiAgentProviderCatalog
|
||||
catalog: AiAgentProviderCatalog,
|
||||
stepType: string
|
||||
): ProviderIssue | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return blocking(
|
||||
@@ -238,6 +247,16 @@ function checkProviderValue(
|
||||
if (typeof kind !== 'string' || kind === '') {
|
||||
return blocking(`provider.kind is missing. Expected ${PROVIDER_SHAPE}`)
|
||||
}
|
||||
if (stepType === 'aidecision' && !DECISION_PROVIDER_KINDS.includes(kind)) {
|
||||
return blocking(
|
||||
`an AI decision step runs on a decision model: provider.kind must be "typesafe" or "cloudflare", not "${kind}"`
|
||||
)
|
||||
}
|
||||
if (stepType !== 'aidecision' && DECISION_PROVIDER_KINDS.includes(kind)) {
|
||||
return blocking(
|
||||
`"${kind}" serves decision models, which answer typed questions rather than messages: use an "aidecision" step rather than an AI agent`
|
||||
)
|
||||
}
|
||||
if (typeof resource !== 'string' || !resource.startsWith('$res:')) {
|
||||
return blocking(
|
||||
`provider.resource must be a "$res:<resource path>" reference. Expected ${PROVIDER_SHAPE}`
|
||||
@@ -277,9 +296,9 @@ function checkProviderValue(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject AI agent steps whose provider config would fail at run time: a malformed provider, a
|
||||
* resource that is not an AI provider resource of the workspace, or a model the endpoint's own
|
||||
* listing rules out.
|
||||
* Reject AI agent and AI decision steps whose provider config would fail at run time: a malformed
|
||||
* provider, a kind the step cannot run on, a resource that is not an AI provider resource of the
|
||||
* workspace, or a model the endpoint's own listing rules out.
|
||||
*
|
||||
* Everything the catalog could not establish is reported through `warnings` instead of blocking,
|
||||
* so an incomplete catalog never rejects a provider that would have worked.
|
||||
@@ -297,7 +316,7 @@ export function validateAiAgentProviders(
|
||||
// A missing provider is reported by collectProviderlessAgentIds, and a javascript
|
||||
// transform resolves at run time with no value to check here.
|
||||
if (!transform || transform.type !== 'static') return
|
||||
const issue = checkProviderValue(transform.value, known)
|
||||
const issue = checkProviderValue(transform.value, known, value.type)
|
||||
if (!issue) return
|
||||
if (issue.blocking) {
|
||||
errors.push(`Step "${mod.id}": ${issue.message}`)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1004,7 +1004,7 @@ const testRunStepSchema = z.object({
|
||||
const testRunStepToolDef = createToolDef(
|
||||
testRunStepSchema,
|
||||
'test_run_step',
|
||||
"Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. An AI agent step takes the inputs a run supplies rather than code arguments, `user_message` above all; its form opens on the step's own configuration, so send only what this run should change. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.",
|
||||
"Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. An AI agent step takes the inputs a run supplies rather than code arguments, `user_message` above all; its form opens on the step's own configuration, so send only what this run should change. An AI decision step takes `state`, what its questions are asked about; its provider and questions are the step's own. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.",
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AI_DECISION_SCHEMA } from '../../flows/flowInfers'
|
||||
import type {
|
||||
ChatCompletionFunctionTool,
|
||||
ChatCompletionMessageFunctionToolCall,
|
||||
@@ -2637,12 +2638,57 @@ export async function resolveFlowStepRun({
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleValue.type === 'aidecision') {
|
||||
return {
|
||||
module: targetModule,
|
||||
// Run alone as a one-step flow preview, as the editor's step test does: the inputs given here
|
||||
// override the step's own, so a state that reads an earlier step can be passed directly.
|
||||
runnableKind: 'flow',
|
||||
// What a run supplies: the state. The provider and questions are the step's own.
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
properties: { state: AI_DECISION_SCHEMA.properties.state },
|
||||
required: ['state'],
|
||||
order: ['state']
|
||||
},
|
||||
startMessage: `Starting test run of AI decision step "${stepId}"...`,
|
||||
startJob: async (args) =>
|
||||
JobService.runFlowPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
value: {
|
||||
modules: [
|
||||
{
|
||||
id: targetModule.id,
|
||||
value: {
|
||||
...moduleValue,
|
||||
input_transforms: {
|
||||
...moduleValue.input_transforms,
|
||||
...Object.fromEntries(
|
||||
Object.keys(args).map((key) => [
|
||||
key,
|
||||
{ type: 'javascript' as const, expr: `flow_input.${key}` }
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
args,
|
||||
path: flowPath
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Step type "${moduleValue.type}" not supported for testing`,
|
||||
error: `Cannot test step of type "${moduleValue.type}"`
|
||||
})
|
||||
throw new Error(
|
||||
`Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow, aiagent`
|
||||
`Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow, aiagent, aidecision`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,11 @@ const OPENAI_MODELS = [
|
||||
'o3'
|
||||
]
|
||||
|
||||
export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
|
||||
/** The providers that chat. Every surface picking a model to talk to (the copilot, flow chat, an
|
||||
* agent's text or image output, eval judges) offers exactly these. */
|
||||
export type ChatAIProvider = Exclude<AIProvider, 'typesafe' | 'cloudflare'>
|
||||
|
||||
export const AI_PROVIDERS: Record<ChatAIProvider, AIProviderDetails> = {
|
||||
openai: {
|
||||
label: 'OpenAI',
|
||||
defaultModels: OPENAI_MODELS
|
||||
@@ -163,6 +167,35 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
|
||||
}
|
||||
}
|
||||
|
||||
/** Decision models answer typed questions instead of messages, so only an AI decision offers
|
||||
* them: TypeSafe's Jev, and Cloudflare's Jev-compatible Clef. The pinned Jev version is there
|
||||
* for flows tuned against its probabilities. */
|
||||
export const DECISION_AI_PROVIDERS: Record<
|
||||
Exclude<AIProvider, ChatAIProvider>,
|
||||
AIProviderDetails
|
||||
> = {
|
||||
typesafe: {
|
||||
label: 'TypeSafe',
|
||||
defaultModels: ['jev-latest', 'jev-1.13.0']
|
||||
},
|
||||
cloudflare: {
|
||||
label: 'Cloudflare',
|
||||
defaultModels: ['clef', 'clef-flash']
|
||||
}
|
||||
}
|
||||
|
||||
/** Label and default models of any provider kind, chat or decision. A kind in neither list (a flow
|
||||
* written by hand or by a newer version) is named as it is and offers no models. */
|
||||
export function aiProviderDetails(provider: AIProvider): AIProviderDetails {
|
||||
return (
|
||||
(AI_PROVIDERS as Record<string, AIProviderDetails>)[provider] ??
|
||||
(DECISION_AI_PROVIDERS as Record<string, AIProviderDetails>)[provider] ?? {
|
||||
label: provider,
|
||||
defaultModels: []
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export interface ModelResponse {
|
||||
id: string
|
||||
object: string
|
||||
@@ -193,6 +226,12 @@ export async function fetchAvailableModels(
|
||||
/** Cap on the listing response, for callers that fetch without a user asking. */
|
||||
maxBytes?: number
|
||||
): Promise<string[]> {
|
||||
// Neither decision provider has an OpenAI-shaped listing: TypeSafe's names models with `name`
|
||||
// and serves one under aliases, and Workers AI's catalog spans every model it hosts.
|
||||
if (provider === 'typesafe' || provider === 'cloudflare') {
|
||||
return DECISION_AI_PROVIDERS[provider].defaultModels
|
||||
}
|
||||
|
||||
// Handle AWS Bedrock separately (needs both foundation-models and inference-profiles)
|
||||
if (provider === 'aws_bedrock') {
|
||||
const headers = {
|
||||
@@ -506,7 +545,7 @@ const DEFAULT_COMPLETION_CONFIG: ChatCompletionCreateParams = {
|
||||
messages: []
|
||||
}
|
||||
|
||||
export const PROVIDER_COMPLETION_CONFIG_MAP: Record<AIProvider, ChatCompletionCreateParams> = {
|
||||
export const PROVIDER_COMPLETION_CONFIG_MAP: Record<ChatAIProvider, ChatCompletionCreateParams> = {
|
||||
openai: DEFAULT_COMPLETION_CONFIG,
|
||||
azure_openai: DEFAULT_COMPLETION_CONFIG,
|
||||
azure_foundry: DEFAULT_COMPLETION_CONFIG,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
|
||||
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { Building, Repeat, Square, ArrowDown, GitBranch, Bot } from 'lucide-svelte'
|
||||
import { Building, Repeat, Square, ArrowDown, GitBranch, Bot, Scale } from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
|
||||
interface Props {
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
{#if module?.value?.type === 'aiagent'}
|
||||
<Bot size={16} class="text-ai" />
|
||||
{:else if module?.value?.type === 'aidecision'}
|
||||
<Scale size={16} class="text-ai" />
|
||||
{:else if module?.value?.type === 'rawscript'}
|
||||
<LanguageIcon lang={module.value.language} width={iconWidth} height={iconHeight} />
|
||||
{:else if module?.summary === 'Terminate flow'}
|
||||
|
||||
@@ -174,7 +174,7 @@ function collectFlowNodeIdsFromNode(node: FlowNodeLike): string[] {
|
||||
export function collectProviderlessAgentIds(modules: unknown): string[] {
|
||||
const ids: string[] = []
|
||||
forEachAiAgentModule(modules, (mod, v) => {
|
||||
if (!v.agent && !v.input_transforms?.provider) {
|
||||
if (v.type === 'aiagent' && !v.agent && !v.input_transforms?.provider) {
|
||||
ids.push(mod.id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AGENT_HISTORY_KEYS } from './agentFormFields'
|
||||
import type { AiAgent, FlowModule, FlowModuleValue, InputTransform } from '$lib/gen'
|
||||
import type { AiAgent, AiDecision, FlowModule, FlowModuleValue, InputTransform } from '$lib/gen'
|
||||
import { loadStoredConfig } from '../aiProviderStorage'
|
||||
import { AI_AGENT_SCHEMA } from './flowInfers'
|
||||
import { forbiddenIds } from './idUtils'
|
||||
@@ -45,7 +45,12 @@ export function getToolNameError(
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const SPECIAL_TOOL_KINDS = ['mcpTool', 'websearchTool', 'aiAgentTool'] as const
|
||||
export const SPECIAL_TOOL_KINDS = [
|
||||
'mcpTool',
|
||||
'websearchTool',
|
||||
'aiAgentTool',
|
||||
'aiDecisionTool'
|
||||
] as const
|
||||
export type SpecialToolKind = (typeof SPECIAL_TOOL_KINDS)[number]
|
||||
|
||||
// Type aliases for better readability
|
||||
@@ -54,6 +59,9 @@ export type FlowModuleTool = AgentTool & { value: { tool_type: 'flowmodule' } &
|
||||
export type AiAgentTool = AgentTool & {
|
||||
value: { tool_type: 'flowmodule' } & { type: 'aiagent' } & FlowModuleValue
|
||||
}
|
||||
export type AiDecisionTool = AgentTool & {
|
||||
value: { tool_type: 'flowmodule' } & AiDecision
|
||||
}
|
||||
export type McpTool = AgentTool & {
|
||||
value: {
|
||||
tool_type: 'mcp'
|
||||
@@ -98,6 +106,32 @@ export function isWebsearchTool(tool: AgentTool): tool is WebsearchTool {
|
||||
* left AI-filled here is dropped from that schema and never reaches the model. */
|
||||
export const AI_AGENT_TOOL_AI_KEYS = ['user_message']
|
||||
|
||||
/** Likewise for a decision used as a tool: the calling agent supplies what it decides about
|
||||
* (`AI_DECISION_TOOL_SCHEMA` in `ai_executor.rs`). */
|
||||
export const AI_DECISION_TOOL_AI_KEYS = ['state']
|
||||
|
||||
/** A new decision: on TypeSafe's latest Jev, with a question to show the shape. */
|
||||
export function newAiDecisionInputTransforms(): AiDecision['input_transforms'] {
|
||||
return {
|
||||
provider: { type: 'static', value: { kind: 'typesafe', resource: '', model: 'jev-latest' } },
|
||||
state: { type: 'static', value: undefined },
|
||||
questions: {
|
||||
type: 'static',
|
||||
value: {
|
||||
intent: {
|
||||
type: 'choice',
|
||||
instructions: 'What does the message ask for?',
|
||||
criteria: {
|
||||
question: 'An answer to a question',
|
||||
action: 'Something to be done',
|
||||
other: 'Anything else'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a tool is called wherever it is named: its own name, else what it points at. Never its id,
|
||||
* which is internal. Undefined when it has nothing to be called yet — an MCP tool with no server
|
||||
* picked is unnamed rather than misnamed, so each surface words that for itself. */
|
||||
@@ -164,6 +198,19 @@ export function createAiAgentTool(id: string): AiAgentTool {
|
||||
} as AiAgentTool
|
||||
}
|
||||
|
||||
/** Create an AI decision tool: the calling agent supplies the state and gets the answers back. */
|
||||
export function createAiDecisionTool(id: string): AiDecisionTool {
|
||||
return {
|
||||
id,
|
||||
summary: '',
|
||||
value: {
|
||||
tool_type: 'flowmodule',
|
||||
type: 'aidecision',
|
||||
input_transforms: { ...newAiDecisionInputTransforms(), state: { type: 'ai' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an MCP tool from resource path
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
|
||||
/** Visit every AI agent module of a module tree, including agents nested inside agent tools.
|
||||
* Takes `unknown` so it also runs on model-produced JSON that has not been schema-checked yet.
|
||||
/** Visit every AI agent and AI decision module of a module tree, including those used as agent
|
||||
* tools. Takes `unknown` so it also runs on model-produced JSON that has not been schema-checked
|
||||
* yet.
|
||||
*
|
||||
* Lives in its own leaf module (types only) so validation code can traverse a flow without
|
||||
* pulling in the editor-side agent tool helpers. */
|
||||
@@ -19,6 +20,8 @@ export function forEachAiAgentModule(
|
||||
visit(v.tools)
|
||||
} else if (v.type === 'forloopflow' || v.type === 'whileloopflow') {
|
||||
visit(v.modules)
|
||||
} else if (v.type === 'aidecision') {
|
||||
cb(mod as FlowModule, v)
|
||||
} else if (v.type === 'branchone' || v.type === 'branchall') {
|
||||
if (v.type === 'branchone') visit(v.default)
|
||||
// Model-produced JSON reaches this before any schema check, so a `branches` that
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FlowModule, OpenFlow } from '$lib/gen'
|
||||
import {
|
||||
checkRouting,
|
||||
choiceBranches,
|
||||
choiceConditionExpr,
|
||||
parseChoiceCondition
|
||||
} from './aiDecisionBranching'
|
||||
import { questionsFitRows, questionsToRows, rowsToQuestions } from './aiDecisionQuestions'
|
||||
|
||||
const questions = {
|
||||
intent: {
|
||||
type: 'choice',
|
||||
instructions: 'What does the customer want?',
|
||||
criteria: { refund: 'Money back', bug: 'Something is broken' }
|
||||
},
|
||||
urgency: { type: 'score', instructions: 'How urgent?', criteria: ['Later', 'Now'] },
|
||||
angry: { type: 'noul', instructions: 'Is the customer angry?' }
|
||||
}
|
||||
|
||||
function flow(branches: { expr: string; modules: FlowModule[] }[]): OpenFlow {
|
||||
return {
|
||||
summary: '',
|
||||
value: {
|
||||
modules: [
|
||||
{
|
||||
id: 'd',
|
||||
value: {
|
||||
type: 'aidecision',
|
||||
input_transforms: {
|
||||
provider: { type: 'static', value: {} },
|
||||
state: { type: 'static', value: 'x' },
|
||||
questions: { type: 'static', value: questions }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ id: 'route', value: { type: 'branchone', branches, default: [] } }
|
||||
]
|
||||
}
|
||||
} as OpenFlow
|
||||
}
|
||||
|
||||
function withBranches(branches: { expr: string; modules: FlowModule[] }[]): [OpenFlow, FlowModule] {
|
||||
const f = flow(branches)
|
||||
return [f, f.value.modules[1]]
|
||||
}
|
||||
|
||||
describe('choice conditions', () => {
|
||||
it('reads back the conditions it writes, even for names that are not identifiers', () => {
|
||||
for (const c of [
|
||||
{ decisionId: 'd', question: 'intent', kind: 'choice' as const, option: 'refund' },
|
||||
{ decisionId: 'd', question: 'the intent', kind: 'choice' as const, option: `it's "odd"` },
|
||||
{ decisionId: 'd', question: 'angry', kind: 'noul' as const, option: 'yes' }
|
||||
]) {
|
||||
expect(parseChoiceCondition(choiceConditionExpr(c))).toEqual(c)
|
||||
}
|
||||
expect(parseChoiceCondition('results.d.output.intent.choice !== "refund"')).toBeUndefined()
|
||||
expect(parseChoiceCondition("results.d.output.intent.choice === 'it\\'s'")).toEqual({
|
||||
decisionId: 'd',
|
||||
question: 'intent',
|
||||
kind: 'choice',
|
||||
option: "it's"
|
||||
})
|
||||
// A tuned threshold still reads as the question's yes branch.
|
||||
expect(parseChoiceCondition('results.d.output.angry.noul > 0.75')).toEqual({
|
||||
decisionId: 'd',
|
||||
question: 'angry',
|
||||
kind: 'noul',
|
||||
option: 'yes'
|
||||
})
|
||||
})
|
||||
|
||||
it('flags the options a branch is missing and the branches whose option is gone', () => {
|
||||
const f = flow([
|
||||
...choiceBranches('d', 'intent', 'choice', ['refund', 'cancel']),
|
||||
{ expr: 'flow_input.x', modules: [] }
|
||||
])
|
||||
expect(checkRouting(f, f.value.modules[1])).toEqual([
|
||||
{
|
||||
decisionId: 'd',
|
||||
question: 'intent',
|
||||
kind: 'choice',
|
||||
missing: ['bug'],
|
||||
stale: [{ index: 1, option: 'cancel' }]
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a removed question', () => {
|
||||
it('leaves every branch routing on it stale', () => {
|
||||
const f = flow(choiceBranches('d', 'gone', 'choice', ['refund']))
|
||||
expect(checkRouting(f, f.value.modules[1])).toEqual([
|
||||
{
|
||||
decisionId: 'd',
|
||||
question: 'gone',
|
||||
kind: 'choice',
|
||||
missing: [],
|
||||
stale: [{ index: 0, option: 'refund' }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a yes branch stale once its question is no longer a yes/no', () => {
|
||||
expect(checkRouting(...withBranches(choiceBranches('d', 'angry', 'noul', ['yes'])))).toEqual([])
|
||||
expect(checkRouting(...withBranches(choiceBranches('d', 'intent', 'noul', ['yes'])))).toEqual([
|
||||
{
|
||||
decisionId: 'd',
|
||||
question: 'intent',
|
||||
kind: 'choice',
|
||||
missing: ['refund', 'bug'],
|
||||
stale: [{ index: 0, option: 'yes' }]
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('questions editor rows', () => {
|
||||
it('round-trips every question type', () => {
|
||||
expect(rowsToQuestions(questionsToRows(questions))).toEqual(questions)
|
||||
expect(questionsFitRows(questions)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves structured instructions to the JSON editor', () => {
|
||||
expect(
|
||||
questionsFitRows({ q: { type: 'noul', instructions: { ask: 'Is it urgent?', context: [] } } })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { getContext, setContext } from 'svelte'
|
||||
import type { BranchOne, FlowModule, OpenFlow } from '$lib/gen'
|
||||
import { branchableQuestions, type BranchKind } from './aiDecisionQuestions'
|
||||
import { getAllModules } from './flowExplorer'
|
||||
|
||||
/** A Branch to one condition on a decision's question, in the forms the branch helper writes and
|
||||
* reads back: one option of a choice, or the `yes` of a yes/no question above a threshold. A
|
||||
* condition written any other way is left alone. */
|
||||
export type ChoiceCondition = {
|
||||
decisionId: string
|
||||
question: string
|
||||
kind: BranchKind
|
||||
option: string
|
||||
}
|
||||
|
||||
/** The one branch the helper writes for a yes/no question; the default branch is its "no". */
|
||||
export const YES = 'yes'
|
||||
const YES_THRESHOLD = 0.5
|
||||
|
||||
const IDENT = /^[A-Za-z_$][\w$]*$/
|
||||
|
||||
function key(k: string): string {
|
||||
return IDENT.test(k) ? `.${k}` : `[${JSON.stringify(k)}]`
|
||||
}
|
||||
|
||||
const KEY = String.raw`(?:\.([A-Za-z_$][\w$]*)|\[("(?:[^"\\]|\\.)*")\])`
|
||||
const CONDITION = new RegExp(
|
||||
String.raw`^results${KEY}\.output${KEY}\.choice === ("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$`
|
||||
)
|
||||
// Any threshold: the helper writes 0.5, and people tune it.
|
||||
const NOUL_CONDITION = new RegExp(
|
||||
String.raw`^results${KEY}\.output${KEY}\.noul >=? (?:0|1|0?\.\d+)$`
|
||||
)
|
||||
|
||||
/** A JS string literal as the helper writes it (double quotes) or as people and the AI chat
|
||||
* usually do (single quotes). */
|
||||
function stringLiteral(literal: string): string {
|
||||
return literal.startsWith("'")
|
||||
? literal.slice(1, -1).replace(/\\(.)/g, '$1')
|
||||
: JSON.parse(literal)
|
||||
}
|
||||
|
||||
export function choiceConditionExpr({
|
||||
decisionId,
|
||||
question,
|
||||
kind,
|
||||
option
|
||||
}: ChoiceCondition): string {
|
||||
const answer = `results${key(decisionId)}.output${key(question)}`
|
||||
return kind === 'noul'
|
||||
? `${answer}.noul >= ${YES_THRESHOLD}`
|
||||
: `${answer}.choice === ${JSON.stringify(option)}`
|
||||
}
|
||||
|
||||
export function parseChoiceCondition(expr: string | undefined): ChoiceCondition | undefined {
|
||||
const trimmed = expr?.trim()
|
||||
try {
|
||||
const m = trimmed?.match(CONDITION)
|
||||
if (m) {
|
||||
return {
|
||||
decisionId: m[1] ?? JSON.parse(m[2]),
|
||||
question: m[3] ?? JSON.parse(m[4]),
|
||||
kind: 'choice',
|
||||
option: stringLiteral(m[5])
|
||||
}
|
||||
}
|
||||
const n = trimmed?.match(NOUL_CONDITION)
|
||||
if (n) {
|
||||
return {
|
||||
decisionId: n[1] ?? JSON.parse(n[2]),
|
||||
question: n[3] ?? JSON.parse(n[4]),
|
||||
kind: 'noul',
|
||||
option: YES
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The questions of a decision step a Branch to one can route on, when they are set statically. */
|
||||
export function decisionChoiceQuestions(
|
||||
decision: FlowModule
|
||||
): { name: string; kind: BranchKind; options: string[] }[] {
|
||||
if (decision.value.type !== 'aidecision') return []
|
||||
const questions = decision.value.input_transforms?.questions
|
||||
return questions?.type === 'static' ? branchableQuestions(questions.value) : []
|
||||
}
|
||||
|
||||
function conditionsOf(routing: FlowModule): (ChoiceCondition | undefined)[] {
|
||||
return routing.value.type === 'branchone'
|
||||
? routing.value.branches.map((b) => parseChoiceCondition(b.expr))
|
||||
: []
|
||||
}
|
||||
|
||||
/** The Branch to one that routes on this question, if any. */
|
||||
export function findRouting(
|
||||
flow: OpenFlow,
|
||||
decisionId: string,
|
||||
question: string
|
||||
): FlowModule | undefined {
|
||||
return getAllModules(flow.value.modules, flow.value.failure_module).find((m) =>
|
||||
conditionsOf(m).some((c) => c?.decisionId === decisionId && c.question === question)
|
||||
)
|
||||
}
|
||||
|
||||
/** Options of the question no branch of `routing` handles yet. */
|
||||
export function missingOptions(
|
||||
routing: FlowModule,
|
||||
decisionId: string,
|
||||
question: string,
|
||||
options: string[]
|
||||
): string[] {
|
||||
const handled = new Set(
|
||||
conditionsOf(routing)
|
||||
.filter((c) => c?.decisionId === decisionId && c.question === question)
|
||||
.map((c) => c!.option)
|
||||
)
|
||||
return options.filter((o) => !handled.has(o))
|
||||
}
|
||||
|
||||
export function choiceBranches(
|
||||
decisionId: string,
|
||||
question: string,
|
||||
kind: BranchKind,
|
||||
options: string[]
|
||||
) {
|
||||
return options.map((option) => ({
|
||||
summary: option,
|
||||
expr: choiceConditionExpr({ decisionId, question, kind, option }),
|
||||
modules: [] as FlowModule[]
|
||||
}))
|
||||
}
|
||||
|
||||
export type RoutingCheck = {
|
||||
decisionId: string
|
||||
question: string
|
||||
kind: BranchKind
|
||||
missing: string[]
|
||||
/** Branches, by index, whose option the question no longer offers. */
|
||||
stale: { index: number; option: string }[]
|
||||
}
|
||||
|
||||
/** For each decision question a Branch to one routes on, what its branches lack and what they
|
||||
* handle that the question no longer offers; a question removed from the decision offers nothing.
|
||||
* Questions set from an expression are skipped: their options are only known at run time. */
|
||||
export function checkRouting(flow: OpenFlow, routing: FlowModule): RoutingCheck[] {
|
||||
const conditions = conditionsOf(routing)
|
||||
const modules = getAllModules(flow.value.modules, flow.value.failure_module)
|
||||
const pairs = new Map<string, { decisionId: string; question: string }>()
|
||||
for (const c of conditions) {
|
||||
if (c) pairs.set(JSON.stringify([c.decisionId, c.question]), c)
|
||||
}
|
||||
const checks: RoutingCheck[] = []
|
||||
for (const { decisionId, question } of pairs.values()) {
|
||||
const decision = modules.find((m) => m.id === decisionId)
|
||||
if (
|
||||
decision?.value.type !== 'aidecision' ||
|
||||
decision.value.input_transforms?.questions?.type !== 'static'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const asked = decisionChoiceQuestions(decision).find((q) => q.name === question)
|
||||
const options = asked?.options ?? []
|
||||
// A branch also goes stale when its question changes kind, a choice turned yes/no or back.
|
||||
const stale = conditions.flatMap((c, index) =>
|
||||
c?.decisionId === decisionId &&
|
||||
c.question === question &&
|
||||
(c.kind !== asked?.kind || !options.includes(c.option))
|
||||
? [{ index, option: c.option }]
|
||||
: []
|
||||
)
|
||||
const missing = asked ? missingOptions(routing, decisionId, question, options) : []
|
||||
if (missing.length || stale.length) {
|
||||
checks.push({ decisionId, question, kind: asked?.kind ?? 'choice', missing, stale })
|
||||
}
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
export function addChoiceBranches(
|
||||
routing: FlowModule,
|
||||
decisionId: string,
|
||||
question: string,
|
||||
kind: BranchKind,
|
||||
options: string[]
|
||||
) {
|
||||
;(routing.value as BranchOne).branches.push(
|
||||
...choiceBranches(decisionId, question, kind, options)
|
||||
)
|
||||
}
|
||||
|
||||
const DECISION_STEP = Symbol('aiDecisionStep')
|
||||
|
||||
/** The AI decision step whose questions are being edited, for the questions editor to offer
|
||||
* branching on them. Its `id` is undefined where branching does not apply: a decision used as
|
||||
* an agent tool, or a test form. */
|
||||
export type AiDecisionStepContext = { readonly id: string | undefined }
|
||||
|
||||
export function setAiDecisionStep(step: AiDecisionStepContext): void {
|
||||
setContext(DECISION_STEP, step)
|
||||
}
|
||||
|
||||
export function getAiDecisionStep(): AiDecisionStepContext | undefined {
|
||||
return getContext(DECISION_STEP)
|
||||
}
|
||||
|
||||
/** Option names as the editor cites them in a sentence: quoted, so a name reads as a value. */
|
||||
export function quoteOptions(options: string[]): string {
|
||||
return options.map((o) => `“${o}”`).join(', ')
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { push } from '$lib/history.svelte'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { FlowEditorContext } from './types'
|
||||
import { addChoiceBranches } from './aiDecisionBranching'
|
||||
import type { BranchKind } from './aiDecisionQuestions'
|
||||
import { getModuleArrayContainer } from './flowTree'
|
||||
import { insertNewModuleAtIndex } from './flowStateUtils.svelte'
|
||||
|
||||
/** Insert a Branch to one right after the decision, with the question's branches (one per option
|
||||
* of a choice, one `yes` for a yes/no), and select it. */
|
||||
export async function branchOnQuestion(
|
||||
{ flowStore, flowStateStore, history, selectionManager }: FlowEditorContext,
|
||||
decisionId: string,
|
||||
question: string,
|
||||
kind: BranchKind,
|
||||
options: string[]
|
||||
): Promise<void> {
|
||||
const container = getModuleArrayContainer(flowStore.val.value, decisionId)
|
||||
if (!container) return
|
||||
push(history, flowStore.val)
|
||||
const modules = (await insertNewModuleAtIndex(
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
container.modules,
|
||||
container.index + 1,
|
||||
'branchone'
|
||||
)) as FlowModule[]
|
||||
const inserted = modules[container.index + 1]
|
||||
addChoiceBranches(inserted, decisionId, question, kind, options)
|
||||
refreshStateStore(flowStore)
|
||||
selectionManager.selectId(inserted.id)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/** The questions of an AI decision as Jev and Clef read them, keyed by question name. */
|
||||
export type DecisionQuestionType = 'choice' | 'score' | 'noul'
|
||||
|
||||
/** One question as the editor holds it: every type's criteria at once, so switching type and back
|
||||
* keeps what was typed. Only the current type's are written out. */
|
||||
export type QuestionRow = {
|
||||
name: string
|
||||
type: DecisionQuestionType
|
||||
instructions: string
|
||||
/** choice: each option and what it means */
|
||||
options: { name: string; description: string }[]
|
||||
/** score: the levels, lowest first */
|
||||
levels: string[]
|
||||
/** noul: what yes and no mean, both optional */
|
||||
yes: string
|
||||
no: string
|
||||
}
|
||||
|
||||
export function emptyQuestion(name: string): QuestionRow {
|
||||
return {
|
||||
name,
|
||||
type: 'choice',
|
||||
instructions: '',
|
||||
options: [
|
||||
{ name: '', description: '' },
|
||||
{ name: '', description: '' }
|
||||
],
|
||||
levels: ['', ''],
|
||||
yes: '',
|
||||
no: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function questionsToRows(value: unknown): QuestionRow[] {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return Object.entries(value as Record<string, any>).map(([name, q]) => {
|
||||
const row = emptyQuestion(name)
|
||||
row.type = q?.type === 'score' || q?.type === 'noul' ? q.type : 'choice'
|
||||
row.instructions = typeof q?.instructions === 'string' ? q.instructions : ''
|
||||
const criteria = q?.criteria
|
||||
if (
|
||||
row.type === 'choice' &&
|
||||
criteria &&
|
||||
typeof criteria === 'object' &&
|
||||
!Array.isArray(criteria)
|
||||
) {
|
||||
row.options = Object.entries(criteria).map(([name, description]) => ({
|
||||
name,
|
||||
description: typeof description === 'string' ? description : ''
|
||||
}))
|
||||
} else if (row.type === 'score' && Array.isArray(criteria)) {
|
||||
row.levels = criteria.map((l) => (typeof l === 'string' ? l : ''))
|
||||
} else if (row.type === 'noul' && criteria && typeof criteria === 'object') {
|
||||
row.yes = typeof criteria.true === 'string' ? criteria.true : ''
|
||||
row.no = typeof criteria.false === 'string' ? criteria.false : ''
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
/** Rows without a name are left out, as are options without a name: neither can be keyed. */
|
||||
export function rowsToQuestions(rows: QuestionRow[]): Record<string, any> {
|
||||
const questions: Record<string, any> = {}
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim()
|
||||
if (!name) continue
|
||||
let criteria: any
|
||||
if (row.type === 'choice') {
|
||||
criteria = Object.fromEntries(
|
||||
row.options.filter((o) => o.name.trim()).map((o) => [o.name.trim(), o.description])
|
||||
)
|
||||
} else if (row.type === 'score') {
|
||||
criteria = row.levels
|
||||
} else {
|
||||
criteria = {
|
||||
...(row.yes ? { true: row.yes } : {}),
|
||||
...(row.no ? { false: row.no } : {})
|
||||
}
|
||||
if (Object.keys(criteria).length === 0) criteria = undefined
|
||||
}
|
||||
questions[name] = {
|
||||
type: row.type,
|
||||
instructions: row.instructions,
|
||||
...(criteria !== undefined ? { criteria } : {})
|
||||
}
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
/** The name the editor should flag on a row, if any: TypeSafe keys questions and options by name,
|
||||
* so an empty or repeated one would be dropped or overwrite another. */
|
||||
export function questionNameError(rows: QuestionRow[], index: number): string | undefined {
|
||||
const name = rows[index].name.trim()
|
||||
if (!name) return 'Name the question'
|
||||
if (rows.findIndex((r) => r.name.trim() === name) !== index)
|
||||
return 'Another question has this name'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function optionNameError(row: QuestionRow, index: number): string | undefined {
|
||||
const name = row.options[index].name.trim()
|
||||
if (name && row.options.findIndex((o) => o.name.trim() === name) !== index) {
|
||||
return 'Another option has this name'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function canonical(v: unknown): string {
|
||||
return JSON.stringify(v, (_, x) =>
|
||||
x && typeof x === 'object' && !Array.isArray(x)
|
||||
? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: x
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the question cards hold all of `value`. TypeSafe also takes structured instructions and
|
||||
* descriptions, which the cards would flatten to text, so those questions stay JSON. */
|
||||
export function questionsFitRows(value: unknown): boolean {
|
||||
if (value == undefined) return true
|
||||
return canonical(rowsToQuestions(questionsToRows(value))) === canonical(value)
|
||||
}
|
||||
|
||||
/** How a Branch to one routes on a question: one branch per option of a choice, or one `yes`
|
||||
* branch for a yes/no question. Scores take a threshold the user picks, so the helper leaves them. */
|
||||
export type BranchKind = 'choice' | 'noul'
|
||||
|
||||
/** The questions a Branch to one can route on, in order, with the branches each one gets. */
|
||||
export function branchableQuestions(
|
||||
value: unknown
|
||||
): { name: string; kind: BranchKind; options: string[] }[] {
|
||||
const branchable: { name: string; kind: BranchKind; options: string[] }[] = []
|
||||
for (const r of questionsToRows(value)) {
|
||||
if (r.type === 'choice') {
|
||||
const options = r.options.map((o) => o.name.trim()).filter(Boolean)
|
||||
branchable.push({ name: r.name, kind: 'choice', options })
|
||||
} else if (r.type === 'noul') {
|
||||
branchable.push({ name: r.name, kind: 'noul', options: ['yes'] })
|
||||
}
|
||||
}
|
||||
return branchable
|
||||
}
|
||||
@@ -251,6 +251,9 @@
|
||||
{:else if flowModuleValue.type === 'aiagent'}
|
||||
<Badge color="indigo">AI Agent</Badge>
|
||||
{@render summaryInput()}
|
||||
{:else if flowModuleValue.type === 'aidecision'}
|
||||
<Badge color="indigo">AI decision</Badge>
|
||||
{@render summaryInput()}
|
||||
{/if}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
emptyMessage?: string
|
||||
/** Where the picker's popover belongs, when the roster is not inside the flow editor. */
|
||||
pickerPortal?: string
|
||||
/** The roster of an agent used as a tool, which cannot run agent or decision tools. */
|
||||
nestedAgent?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -28,7 +30,8 @@
|
||||
onAddTool = undefined,
|
||||
onDeleteTool = undefined,
|
||||
emptyMessage = 'No tools yet. Add one from the agent on the flow graph.',
|
||||
pickerPortal = '#flow-editor'
|
||||
pickerPortal = '#flow-editor',
|
||||
nestedAgent = false
|
||||
}: Props = $props()
|
||||
|
||||
let funcDesc = $state('')
|
||||
@@ -37,6 +40,7 @@
|
||||
function toolKind(tool: AgentTool): string | undefined {
|
||||
const value = tool?.value as Record<string, any>
|
||||
if (value?.type === 'aiagent') return 'agent'
|
||||
if (value?.type === 'aidecision') return 'decision'
|
||||
if (value?.tool_type === 'mcp') return 'MCP'
|
||||
if (value?.tool_type === 'websearch') return 'web search'
|
||||
return undefined
|
||||
@@ -81,6 +85,7 @@
|
||||
<InsertModuleInner
|
||||
bind:funcDesc
|
||||
toolMode
|
||||
{nestedAgent}
|
||||
on:close={close}
|
||||
on:new={(e) => (onAddTool?.(e.detail), close())}
|
||||
on:insert={(e) => (onAddTool?.(e.detail), close())}
|
||||
@@ -99,6 +104,7 @@
|
||||
on:pickMcpTool={() => (onAddTool?.({ kind: 'mcpTool' }), close())}
|
||||
on:pickWebsearchTool={() => (onAddTool?.({ kind: 'websearchTool' }), close())}
|
||||
on:pickAiAgentTool={() => (onAddTool?.({ kind: 'aiAgentTool' }), close())}
|
||||
on:pickAiDecisionTool={() => (onAddTool?.({ kind: 'aiDecisionTool' }), close())}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
|
||||
@@ -568,6 +568,7 @@
|
||||
{onAddTool}
|
||||
{onDeleteTool}
|
||||
pickerPortal={toolPickerPortal}
|
||||
nestedAgent={isAgentTool}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Inert rather than merely button-less: every control below writes into
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { Split } from 'lucide-svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { push } from '$lib/history.svelte'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import {
|
||||
addChoiceBranches,
|
||||
checkRouting,
|
||||
quoteOptions,
|
||||
decisionChoiceQuestions,
|
||||
findRouting,
|
||||
missingOptions
|
||||
} from '../aiDecisionBranching'
|
||||
import { findModuleInFlow } from '../flowTree'
|
||||
import { branchOnQuestion } from '../aiDecisionInsert'
|
||||
import StepIdBadge from './StepIdBadge.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { dfs } from '../dfs'
|
||||
|
||||
interface Props {
|
||||
decisionId: string
|
||||
question: string
|
||||
/** Name the question in the copy, for when it is not shown in a card of its own. */
|
||||
named?: boolean
|
||||
}
|
||||
|
||||
let { decisionId, question, named = false }: Props = $props()
|
||||
|
||||
const ctx = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const { flowStore, flowStateStore, history, selectionManager } = ctx
|
||||
|
||||
// Read from the stored step, so what this offers matches what the branches are checked against.
|
||||
let asked = $derived.by(() => {
|
||||
const decision = findModuleInFlow(flowStore.val.value, decisionId)
|
||||
return decision && decisionChoiceQuestions(decision).find((q) => q.name === question)
|
||||
})
|
||||
let options = $derived(asked?.options ?? [])
|
||||
let routing = $derived(findRouting(flowStore.val, decisionId, question))
|
||||
let missing = $derived(routing ? missingOptions(routing, decisionId, question, options) : options)
|
||||
let stale = $derived(
|
||||
routing
|
||||
? (checkRouting(flowStore.val, routing).find(
|
||||
(c) => c.decisionId === decisionId && c.question === question
|
||||
)?.stale ?? [])
|
||||
: []
|
||||
)
|
||||
|
||||
// Steps inside the stale branches, which removing them deletes along with them.
|
||||
let staleSteps = $derived.by(() => {
|
||||
if (routing?.value.type !== 'branchone') return 0
|
||||
const branches = routing.value.branches
|
||||
return stale.reduce((n, s) => n + dfs(branches[s.index]?.modules ?? [], (m) => m.id).length, 0)
|
||||
})
|
||||
let confirmingRemoval = $state(false)
|
||||
|
||||
// One undo entry for the whole removal, which is one click.
|
||||
function removeStale(target: FlowModule) {
|
||||
if (target.value.type !== 'branchone') return
|
||||
push(history, flowStore.val)
|
||||
const branches = target.value.branches
|
||||
// From the last, so each index still points at its branch once the ones after it are gone.
|
||||
for (const { index } of [...stale].sort((a, b) => b.index - a.index)) {
|
||||
for (const id of dfs(branches[index]?.modules ?? [], (m) => m.id)) {
|
||||
delete flowStateStore.val[id]
|
||||
}
|
||||
branches.splice(index, 1)
|
||||
}
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
|
||||
function addMissing(target: FlowModule) {
|
||||
push(history, flowStore.val)
|
||||
addChoiceBranches(target, decisionId, question, asked?.kind ?? 'choice', missing)
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !routing}
|
||||
{#if asked && options.length > 0}
|
||||
{@const kind = asked.kind}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
wrapperClasses="self-start"
|
||||
startIcon={{ icon: Split }}
|
||||
title="Add a Branch to one after this step, with one branch per option"
|
||||
onClick={() => branchOnQuestion(ctx, decisionId, question, kind, options)}
|
||||
>
|
||||
Branch on {named ? question : 'this question'}
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const target = routing}
|
||||
<div class="flex items-center gap-2 text-xs text-secondary">
|
||||
<Split size={12} />
|
||||
<span>{named ? `${question} branched in` : 'Branched in'}</span>
|
||||
<StepIdBadge id={target.id} />
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="xs"
|
||||
title="Open the Branch to one"
|
||||
onClick={() => selectionManager.selectId(target.id)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
{#if missing.length > 0}
|
||||
<Alert
|
||||
type="info"
|
||||
size="xs"
|
||||
title="{missing.length === 1 ? 'An option has' : 'Options have'} no branch yet: {quoteOptions(
|
||||
missing
|
||||
)}"
|
||||
actions={[
|
||||
{
|
||||
label: missing.length === 1 ? 'Add branch' : 'Add branches',
|
||||
onClick: () => addMissing(target)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
{#if stale.length > 0}
|
||||
<Alert
|
||||
type="warning"
|
||||
size="xs"
|
||||
title="{stale.length === 1 ? 'A branch handles an option' : 'Branches handle options'} {named
|
||||
? `the ${question} question`
|
||||
: 'this question'} no longer has: {quoteOptions(stale.map((s) => s.option))}"
|
||||
actions={[
|
||||
{
|
||||
label: stale.length === 1 ? 'Remove branch' : 'Remove branches',
|
||||
onClick: () => (staleSteps > 0 ? (confirmingRemoval = true) : removeStale(target))
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{@const one = stale.length === 1}
|
||||
{@const steps = staleSteps === 1 ? '1 step' : `${staleSteps} steps`}
|
||||
<ConfirmationModal
|
||||
open={confirmingRemoval}
|
||||
title="Remove the {one ? 'branch' : 'branches'} and {steps}?"
|
||||
confirmationText="Remove"
|
||||
onCanceled={() => (confirmingRemoval = false)}
|
||||
onConfirmed={() => {
|
||||
confirmingRemoval = false
|
||||
removeStale(target)
|
||||
}}
|
||||
>
|
||||
The {one ? 'branch' : 'branches'} for {quoteOptions(stale.map((s) => s.option))} in
|
||||
<StepIdBadge id={target.id} />
|
||||
{one ? 'contains' : 'contain'}
|
||||
{steps}, which will be deleted too.
|
||||
</ConfirmationModal>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Badge, Tab, Tabs } from '$lib/components/common'
|
||||
import { Alert, Badge, Tab, Tabs } from '$lib/components/common'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import { GripVertical, Plus, Trash2 } from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
@@ -22,6 +22,14 @@
|
||||
import BranchPredicateEditor from './BranchPredicateEditor.svelte'
|
||||
import FlowRunSettings from './FlowRunSettings.svelte'
|
||||
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
|
||||
import { push } from '$lib/history.svelte'
|
||||
import {
|
||||
addChoiceBranches,
|
||||
checkRouting,
|
||||
quoteOptions,
|
||||
type RoutingCheck
|
||||
} from '../aiDecisionBranching'
|
||||
import StepIdBadge from './StepIdBadge.svelte'
|
||||
|
||||
interface Props {
|
||||
flowModule: FlowModule
|
||||
@@ -102,6 +110,15 @@
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
|
||||
// Kept in step with the AI decision questions its branches were generated from.
|
||||
let routingChecks = $derived(checkRouting(flowStore.val, flowModule))
|
||||
|
||||
function addMissing(check: RoutingCheck) {
|
||||
push(history, flowStore.val)
|
||||
addChoiceBranches(flowModule, check.decisionId, check.question, check.kind, check.missing)
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
|
||||
let runSettings: FlowRunSettings | undefined = $state(undefined)
|
||||
let selectedTab = $state('branches')
|
||||
|
||||
@@ -140,6 +157,38 @@
|
||||
{#if selectedTab === 'branches'}
|
||||
<section>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each routingChecks as check (JSON.stringify([check.decisionId, check.question]))}
|
||||
{#if check.missing.length > 0}
|
||||
<Alert
|
||||
type="info"
|
||||
size="xs"
|
||||
title="{check.missing.length === 1
|
||||
? 'An option has'
|
||||
: 'Options have'} no branch yet: {quoteOptions(check.missing)}"
|
||||
actions={[
|
||||
{
|
||||
label: check.missing.length === 1 ? 'Add branch' : 'Add branches',
|
||||
onClick: () => addMissing(check)
|
||||
}
|
||||
]}
|
||||
>
|
||||
From the {check.question} question of <StepIdBadge id={check.decisionId} />
|
||||
</Alert>
|
||||
{/if}
|
||||
{#each check.stale as stale (stale.index)}
|
||||
<Alert
|
||||
type="warning"
|
||||
size="xs"
|
||||
title="Branch {stale.index + 1} handles {quoteOptions([
|
||||
stale.option
|
||||
])}, which is no longer an option"
|
||||
actions={[{ label: 'Remove branch', onClick: () => removeBranch(stale.index) }]}
|
||||
>
|
||||
The {check.question} question of <StepIdBadge id={check.decisionId} /> no longer
|
||||
offers it.
|
||||
</Alert>
|
||||
{/each}
|
||||
{/each}
|
||||
<section
|
||||
class="flex flex-col gap-3"
|
||||
use:dragHandleZone={{ items, flipDurationMs: 150, dropTargetStyle: {} }}
|
||||
|
||||
@@ -181,7 +181,12 @@
|
||||
['While loop', 'whileloop'],
|
||||
['Branch to one', 'branchone'],
|
||||
['Branch to all', 'branchall'],
|
||||
...(customUi?.aiAgent != false ? ([['AI Agent', 'aiagent']] as [string, string][]) : [])
|
||||
...(customUi?.aiAgent != false
|
||||
? ([
|
||||
['AI Agent', 'aiagent'],
|
||||
['AI decision', 'aidecision']
|
||||
] as [string, string][])
|
||||
: [])
|
||||
]
|
||||
|
||||
let topLevelNodes: [string, string][] = $state([])
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
import AgentToolBindings from './AgentToolBindings.svelte'
|
||||
import { getLinkedAgentTools, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
|
||||
import { flowLocalAgentSchema } from '../agentResourceUtils'
|
||||
import { AI_AGENT_TOOL_AI_KEYS } from '../agentToolUtils'
|
||||
import { AI_AGENT_TOOL_AI_KEYS, AI_DECISION_TOOL_AI_KEYS } from '../agentToolUtils'
|
||||
import { setAiDecisionStep } from '../aiDecisionBranching'
|
||||
import DiffEditor from '$lib/components/DiffEditor.svelte'
|
||||
import type { ButtonProp } from '$lib/components/diffEditorTypes'
|
||||
import { loadSchemaFromModule } from '../flowInfers'
|
||||
@@ -154,6 +155,12 @@
|
||||
siblingToolNames = undefined
|
||||
}: Props = $props()
|
||||
|
||||
setAiDecisionStep({
|
||||
get id() {
|
||||
return flowModule.value.type === 'aidecision' && !isAgentTool ? flowModule.id : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Key for the linked-agent tools store. Ancestry-qualified for a nested agent tool, whose id
|
||||
// comes from a resource and is not flow-global — it could otherwise alias a top-level step and
|
||||
// read, then overwrite, that step's tools. Flow modules keep their bare id, which is what the
|
||||
@@ -318,7 +325,8 @@
|
||||
flowModule.value.type == 'rawscript' ||
|
||||
flowModule.value.type == 'script' ||
|
||||
flowModule.value.type == 'flow' ||
|
||||
flowModule.value.type == 'aiagent'
|
||||
flowModule.value.type == 'aiagent' ||
|
||||
flowModule.value.type == 'aidecision'
|
||||
) {
|
||||
if (!deepEqual(flowModule.value.input_transforms, input_transforms)) {
|
||||
flowModule.value.input_transforms = input_transforms
|
||||
@@ -867,7 +875,11 @@
|
||||
console.log('tagChange', e.detail)
|
||||
if (flowModule.value.type == 'script') {
|
||||
flowModule.value.tag_override = e.detail
|
||||
} else if (flowModule.value.type == 'rawscript' || flowModule.value.type == 'aiagent') {
|
||||
} else if (
|
||||
flowModule.value.type == 'rawscript' ||
|
||||
flowModule.value.type == 'aiagent' ||
|
||||
flowModule.value.type == 'aidecision'
|
||||
) {
|
||||
flowModule.value.tag = e.detail
|
||||
}
|
||||
}}
|
||||
@@ -1144,7 +1156,7 @@
|
||||
</Tab>
|
||||
{/if}
|
||||
</Tabs>
|
||||
{#if visibleSelected === 'inputs' && (flowModule.value.type == 'rawscript' || flowModule.value.type == 'script' || flowModule.value.type == 'flow' || flowModule.value.type == 'aiagent')}
|
||||
{#if visibleSelected === 'inputs' && (flowModule.value.type == 'rawscript' || flowModule.value.type == 'script' || flowModule.value.type == 'flow' || flowModule.value.type == 'aiagent' || flowModule.value.type == 'aidecision')}
|
||||
<div class="flex-1 overflow-auto" id="flow-editor-step-input">
|
||||
<!-- `sidePane` under `staticOnly`: that column only opens on a connect,
|
||||
and there is no connect button to open it. -->
|
||||
@@ -1292,7 +1304,10 @@
|
||||
{isAgentTool}
|
||||
noConnect={staticOnly}
|
||||
noJavascript={staticOnly}
|
||||
allowedAiTransforms={undefined}
|
||||
allowedAiTransforms={isAgentTool &&
|
||||
flowModule.value.type === 'aidecision'
|
||||
? AI_DECISION_TOOL_AI_KEYS
|
||||
: undefined}
|
||||
helperScript={retrieveDynCodeAndLang(flowModule.value)}
|
||||
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
|
||||
workspace={opWs}
|
||||
@@ -1498,7 +1513,7 @@
|
||||
</Splitpanes>
|
||||
{/snippet}
|
||||
|
||||
{#if flowModule.value.type === 'aiagent' || (noEditor && flowModule.value.type !== 'flow')}
|
||||
{#if flowModule.value.type === 'aiagent' || flowModule.value.type === 'aidecision' || (noEditor && flowModule.value.type !== 'flow')}
|
||||
<!-- Top pane has no content to show (aiagent has no editor; rawscript/script
|
||||
gate their content on !noEditor). Skip the Splitpanes wrapper entirely so
|
||||
there's no orphan splitter. type === 'flow' still renders FlowPathViewer
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if module.value.type === 'aiagent' && customUi?.tagEdit != false}
|
||||
{#if (module.value.type === 'aiagent' || module.value.type === 'aidecision') && customUi?.tagEdit != false}
|
||||
<FlowModuleWorkerTagSelect
|
||||
isPreprocessor={false}
|
||||
placeholder={customUi?.tagSelectPlaceholder}
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
preprocessorModule={selectedId === 'preprocessor'}
|
||||
/>
|
||||
{/if}
|
||||
{:else if flowModule.value.type === 'rawscript' || flowModule.value.type === 'script' || flowModule.value.type === 'flow' || flowModule.value.type === 'aiagent'}
|
||||
{:else if flowModule.value.type === 'rawscript' || flowModule.value.type === 'script' || flowModule.value.type === 'flow' || flowModule.value.type === 'aiagent' || flowModule.value.type === 'aidecision'}
|
||||
<FlowModuleComponent
|
||||
{noEditor}
|
||||
bind:flowModule
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/common'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
|
||||
let { id }: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- Styled like the id badge on a graph node, so a step named in text reads as the same thing. -->
|
||||
<Badge
|
||||
color="transparent"
|
||||
class="border-none"
|
||||
wrapperClass="rounded-md bg-component-virtual-node text-emphasis align-middle"
|
||||
baseClass="!px-1"
|
||||
title={id}
|
||||
>
|
||||
<span class="text-2xs font-normal">{id}</span>
|
||||
</Badge>
|
||||
@@ -51,7 +51,7 @@ function filterDependentComponents(modules: FlowModule[], id: string): Record<st
|
||||
modules
|
||||
.map((mod) => [mod.id, getModuleExprs(mod).filter((expr) => expr.includes(`flow_input`))])
|
||||
.filter((x) => x[1].length > 0)
|
||||
)
|
||||
)
|
||||
: Object.fromEntries(
|
||||
modules
|
||||
.map((mod) => [
|
||||
@@ -62,7 +62,7 @@ function filterDependentComponents(modules: FlowModule[], id: string): Record<st
|
||||
})
|
||||
])
|
||||
.filter((x) => x[1].length > 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getModuleExprs(x: FlowModule): string[] {
|
||||
@@ -73,7 +73,12 @@ function getModuleExprs(x: FlowModule): string[] {
|
||||
x.value.branches.map((branch) => {
|
||||
exprs.push(branch.expr)
|
||||
})
|
||||
} else if (x.value.type === 'flow' || x.value.type === 'script' || x.value.type == 'rawscript') {
|
||||
} else if (
|
||||
x.value.type === 'flow' ||
|
||||
x.value.type === 'script' ||
|
||||
x.value.type == 'rawscript' ||
|
||||
x.value.type == 'aidecision'
|
||||
) {
|
||||
exprs.push(...exprsOfInputTransforms(x.value.input_transforms))
|
||||
exprs.push(...getExpr(x.sleep))
|
||||
if (x.stop_after_if?.expr) {
|
||||
|
||||
@@ -212,6 +212,35 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
]
|
||||
}
|
||||
|
||||
/** The inputs of an AI decision step: one call to a decision model (TypeSafe's Jev, or
|
||||
* Cloudflare's Clef). */
|
||||
export const AI_DECISION_SCHEMA: Schema = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
properties: {
|
||||
provider: {
|
||||
type: 'object',
|
||||
format: 'ai-decision-provider',
|
||||
description: 'The decision model to ask: TypeSafe (Jev) or Cloudflare (Clef).'
|
||||
},
|
||||
// Untyped, so the static editor takes any JSON: TypeSafe reads a string, an object or an
|
||||
// array of strings.
|
||||
state: {
|
||||
type: undefined,
|
||||
description:
|
||||
'The content to evaluate: a text, or an object with named parts. Extra detail makes the answers less accurate.',
|
||||
placeholder: 'e.g. "My order is late" or { "message": "..." }'
|
||||
},
|
||||
questions: {
|
||||
type: 'object',
|
||||
format: 'ai-decision-questions',
|
||||
description: 'What to ask about the state'
|
||||
}
|
||||
},
|
||||
required: ['provider', 'state', 'questions'],
|
||||
type: 'object',
|
||||
order: ['provider', 'state', 'questions']
|
||||
}
|
||||
|
||||
/** Memory shapes the editor no longer offers for new steps. The step form offers one only to a step that still holds it,
|
||||
* since the one-of field rewrites a value that matches none of its options. No field carries a
|
||||
* default: the form writes one into a missing field on open, and a missing count runs as off. */
|
||||
@@ -382,6 +411,15 @@ export async function loadSchemaFromModule(
|
||||
// one step offers would otherwise become every step's.
|
||||
schema: structuredClone(AI_AGENT_SCHEMA)
|
||||
}
|
||||
} else if (mod.type === 'aidecision') {
|
||||
const input_transforms = mod.input_transforms ?? {}
|
||||
return {
|
||||
input_transforms: Object.keys(AI_DECISION_SCHEMA.properties ?? {}).reduce((accu, key) => {
|
||||
accu[key] = input_transforms[key] ?? { type: 'static', value: undefined }
|
||||
return accu
|
||||
}, {}),
|
||||
schema: structuredClone(AI_DECISION_SCHEMA)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -29,8 +29,10 @@ import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilde
|
||||
import {
|
||||
agentToolToFlowModule,
|
||||
createAiAgentTool,
|
||||
createAiDecisionTool,
|
||||
createMcpTool,
|
||||
createWebsearchTool,
|
||||
newAiDecisionInputTransforms,
|
||||
newFlowModuleAgentTool,
|
||||
SPECIAL_TOOL_KINDS,
|
||||
type AgentTool,
|
||||
@@ -49,7 +51,8 @@ export async function loadFlowModuleState(
|
||||
flowModule.value.type == 'script' ||
|
||||
flowModule.value.type == 'rawscript' ||
|
||||
flowModule.value.type == 'flow' ||
|
||||
flowModule.value.type == 'aiagent'
|
||||
flowModule.value.type == 'aiagent' ||
|
||||
flowModule.value.type == 'aidecision'
|
||||
) {
|
||||
flowModule.value.input_transforms = input_transforms
|
||||
}
|
||||
@@ -237,6 +240,18 @@ export async function createAiAgent(
|
||||
return [aiAgentFlowModules, flowModuleState]
|
||||
}
|
||||
|
||||
export async function createAiDecision(id: string): Promise<[FlowModule, FlowModuleState]> {
|
||||
const decisionModule: FlowModule = {
|
||||
id,
|
||||
summary: '',
|
||||
value: { type: 'aidecision', input_transforms: newAiDecisionInputTransforms() }
|
||||
}
|
||||
|
||||
const flowModuleState = await loadFlowModuleState(decisionModule)
|
||||
|
||||
return [decisionModule, flowModuleState]
|
||||
}
|
||||
|
||||
export async function createFlow(id: string): Promise<[FlowModule, FlowModuleState]> {
|
||||
const flowFlowModules: FlowModule = {
|
||||
id,
|
||||
@@ -501,6 +516,8 @@ export async function createNewModule(
|
||||
;[module, state] = await createBranches(module.id)
|
||||
} else if (kind == 'branchall') {
|
||||
;[module, state] = await createBranchAll(module.id)
|
||||
} else if (kind == 'aidecision') {
|
||||
;[module, state] = await createAiDecision(module.id)
|
||||
} else if (kind == 'aiagent') {
|
||||
;[module, state] = await createAiAgent(
|
||||
module.id,
|
||||
@@ -579,6 +596,14 @@ export async function insertNewModuleAtIndex(
|
||||
)
|
||||
;(modules as AgentTool[]).splice(index, 0, aiAgentTool)
|
||||
return modules as AgentTool[]
|
||||
} else if (toolKind === 'aiDecisionTool') {
|
||||
const aiDecisionTool = createAiDecisionTool(module.id)
|
||||
flowStateStore.val[module.id] = await loadFlowModuleState(
|
||||
agentToolToFlowModule(aiDecisionTool),
|
||||
workspace
|
||||
)
|
||||
;(modules as AgentTool[]).splice(index, 0, aiDecisionTool)
|
||||
return modules as AgentTool[]
|
||||
} else if (toolKind === 'flowmoduleTool') {
|
||||
const agentTool = newFlowModuleAgentTool(module)
|
||||
;(modules as AgentTool[]).splice(index, 0, agentTool)
|
||||
|
||||
@@ -771,7 +771,8 @@
|
||||
if (
|
||||
mod.value.type == 'rawscript' ||
|
||||
mod.value.type == 'script' ||
|
||||
mod.value.type == 'flow'
|
||||
mod.value.type == 'flow' ||
|
||||
mod.value.type == 'aidecision'
|
||||
) {
|
||||
mod.value.input_transforms = Object.fromEntries(
|
||||
Object.entries(mod.value.input_transforms).map(([k, v]) => {
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
kind?: 'script' | 'trigger' | 'preprocessor' | 'failure'
|
||||
allowTrigger?: boolean
|
||||
toolMode?: boolean
|
||||
/** Picking a tool for an agent that is itself a tool, which cannot run agent or decision
|
||||
* tools: the server drops them from its roster. */
|
||||
nestedAgent?: boolean
|
||||
/** Narrow layout (450px instead of 650px). Defaults on for the preprocessor
|
||||
* and failure pickers; set it when the container cannot fit the wide one. */
|
||||
small?: boolean
|
||||
@@ -41,6 +44,7 @@
|
||||
kind = 'script',
|
||||
allowTrigger = true,
|
||||
toolMode = false,
|
||||
nestedAgent = false,
|
||||
small: smallProp = undefined
|
||||
}: Props = $props()
|
||||
|
||||
@@ -247,6 +251,16 @@
|
||||
dispatch('close')
|
||||
}}
|
||||
/>
|
||||
{#if !nestedAgent}
|
||||
<TopLevelNode
|
||||
label="AI decision"
|
||||
chevron={false}
|
||||
onSelect={() => {
|
||||
dispatch('pickAiDecisionTool')
|
||||
dispatch('close')
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if customUi?.triggers != false && allowTrigger}
|
||||
<TopLevelNode
|
||||
@@ -323,6 +337,13 @@
|
||||
stepGen?.focus()
|
||||
}}
|
||||
/>
|
||||
<TopLevelNode
|
||||
label="AI decision"
|
||||
onSelect={() => {
|
||||
dispatch('close')
|
||||
dispatch('new', { kind: 'aidecision' })
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if customUi?.aiSandbox != false && !$operatorBuilderFlows}
|
||||
<TopLevelNode
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
{nodeState}
|
||||
label={mod.summary ||
|
||||
(mod.value.type === 'aiagent' ? (mod.value.agent ?? 'AI Agent') : undefined) ||
|
||||
(mod.value.type === 'aidecision' ? 'AI decision' : undefined) ||
|
||||
(mod.id === 'preprocessor'
|
||||
? 'Preprocessor'
|
||||
: mod.id.startsWith('failure')
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
Globe,
|
||||
Plug,
|
||||
Repeat,
|
||||
Scale,
|
||||
Square,
|
||||
Zap
|
||||
} from 'lucide-svelte'
|
||||
@@ -48,6 +49,7 @@
|
||||
'Branch to one': { icon: GitBranch },
|
||||
'Branch to all': { icon: GitBranch },
|
||||
'AI Agent': { icon: BotIcon, showChevron: true, iconClass: 'text-ai' },
|
||||
'AI decision': { icon: Scale, iconClass: 'text-ai' },
|
||||
'AI Sandbox': { icon: BotIcon, showChevron: true, iconClass: 'text-ai' },
|
||||
'Claude Code': { icon: BotIcon, iconClass: 'text-ai' },
|
||||
MCP: { icon: Plug },
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
if (type === 'flow') return 'Flow'
|
||||
if (type === 'identity') return 'Identity'
|
||||
if (type === 'aiagent') return 'AI Agent'
|
||||
if (type === 'aidecision') return 'AI decision'
|
||||
return mod.id
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ export type InsertKind =
|
||||
| 'approval'
|
||||
| 'end'
|
||||
| 'aiagent'
|
||||
| 'aidecision'
|
||||
| 'mcpTool'
|
||||
| 'websearchTool'
|
||||
| 'aiAgentTool'
|
||||
| 'aiDecisionTool'
|
||||
|
||||
export type InlineScript = {
|
||||
language: RawScript['language']
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
NewAiToolN,
|
||||
NodeLayout
|
||||
} from '../../graphBuilder.svelte'
|
||||
import { Bot, Globe, MessageCircle, Play, Plug, Wrench, X } from 'lucide-svelte'
|
||||
import { Bot, Globe, MessageCircle, Play, Plug, Scale, Wrench, X } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Edge, Node } from '@xyflow/svelte'
|
||||
|
||||
@@ -407,6 +407,8 @@
|
||||
<Plug size={16} class="ml-1 shrink-0" />
|
||||
{:else if data.type === 'aiagent'}
|
||||
<Bot size={16} class="ml-1 shrink-0" />
|
||||
{:else if data.type === 'aidecision'}
|
||||
<Scale size={16} class="ml-1 shrink-0" />
|
||||
{:else}
|
||||
<Wrench size={16} class="ml-1 shrink-0" />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { GitBranchPlus } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import type { FlowEditorContext } from '$lib/components/flows/types'
|
||||
import { decisionChoiceQuestions, findRouting } from '$lib/components/flows/aiDecisionBranching'
|
||||
import { branchOnQuestion } from '$lib/components/flows/aiDecisionInsert'
|
||||
import { findModuleInFlow } from '$lib/components/flows/flowTree'
|
||||
|
||||
interface Props {
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
let { moduleId }: Props = $props()
|
||||
|
||||
// Absent in graphs drawn outside the flow editor, which have nothing to insert into.
|
||||
const ctx = getContext<FlowEditorContext | undefined>('FlowEditorContext')
|
||||
|
||||
// The first question nothing branches on yet; the step's question cards offer the rest.
|
||||
let question = $derived.by(() => {
|
||||
if (!ctx) return undefined
|
||||
const decision = findModuleInFlow(ctx.flowStore.val.value, moduleId)
|
||||
if (!decision) return undefined
|
||||
return decisionChoiceQuestions(decision).find(
|
||||
(q) => q.options.length > 0 && !findRouting(ctx.flowStore.val, moduleId, q.name)
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if ctx && question}
|
||||
{@const q = question}
|
||||
<div class="absolute top-1/2 -right-8 -translate-y-1/2 z-10">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
btnClasses="bg-surface text-secondary"
|
||||
startIcon={{ icon: GitBranchPlus }}
|
||||
title="Branch on {q.name}"
|
||||
onClick={() => branchOnQuestion(ctx, moduleId, q.name, q.kind, q.options)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,6 +2,7 @@
|
||||
import MapItem from '$lib/components/flows/map/MapItem.svelte'
|
||||
import { GitBranchPlus, Move, Copy, Trash2, StickyNote, PictureInPicture2 } from 'lucide-svelte'
|
||||
import NodeWrapper from './NodeWrapper.svelte'
|
||||
import AiDecisionNodeBranch from './AiDecisionNodeBranch.svelte'
|
||||
import type { ModuleN } from '../../graphBuilder.svelte'
|
||||
import { jobToGraphModuleState } from '$lib/components/modulesTest.svelte'
|
||||
import { getNoteEditorContext } from '../../noteEditor.svelte'
|
||||
@@ -237,5 +238,8 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.module?.value?.type === 'aidecision' && data.insertable}
|
||||
<AiDecisionNodeBranch moduleId={data.id} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</NodeWrapper>
|
||||
|
||||
@@ -114,6 +114,14 @@
|
||||
})
|
||||
close()
|
||||
}}
|
||||
on:pickAiDecisionTool={(e) => {
|
||||
data.eventHandlers.insert({
|
||||
index: -1,
|
||||
agentId: data.agentModuleId,
|
||||
kind: 'aiDecisionTool'
|
||||
})
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
|
||||
@@ -629,6 +629,7 @@ export type InputCat =
|
||||
| 'dynamic'
|
||||
| 'json-schema'
|
||||
| 'ai-provider'
|
||||
| 'ai-decision-questions'
|
||||
|
||||
export namespace DynamicInput {
|
||||
const DYN_FORMAT_PREFIX = ['dynmultiselect-', 'dynselect-']
|
||||
@@ -707,8 +708,10 @@ export function setInputCat(
|
||||
return 'list'
|
||||
} else if (type == 'object' && format?.startsWith('resource')) {
|
||||
return 'resource-object'
|
||||
} else if (type == 'object' && format == 'ai-provider') {
|
||||
} else if (type == 'object' && (format == 'ai-provider' || format == 'ai-decision-provider')) {
|
||||
return 'ai-provider'
|
||||
} else if (type == 'object' && format == 'ai-decision-questions') {
|
||||
return 'ai-decision-questions'
|
||||
} else if (type == 'object' && DynamicInput.isDynInputFormat(format)) {
|
||||
return 'dynamic'
|
||||
} else if (!type || type == 'object' || type == 'array') {
|
||||
|
||||
@@ -479,6 +479,8 @@ components:
|
||||
- togetherai
|
||||
- customai
|
||||
- aws_bedrock
|
||||
- typesafe
|
||||
- cloudflare
|
||||
|
||||
ProviderConfig:
|
||||
type: object
|
||||
@@ -685,6 +687,7 @@ components:
|
||||
- $ref: '#/components/schemas/BranchAll'
|
||||
- $ref: '#/components/schemas/Identity'
|
||||
- $ref: '#/components/schemas/AiAgent'
|
||||
- $ref: '#/components/schemas/AiDecision'
|
||||
discriminator:
|
||||
propertyName: type
|
||||
mapping:
|
||||
@@ -697,6 +700,7 @@ components:
|
||||
branchall: '#/components/schemas/BranchAll'
|
||||
identity: '#/components/schemas/Identity'
|
||||
aiagent: '#/components/schemas/AiAgent'
|
||||
aidecision: '#/components/schemas/AiDecision'
|
||||
|
||||
RawScript:
|
||||
type: object
|
||||
@@ -1224,6 +1228,62 @@ components:
|
||||
- type
|
||||
- input_transforms
|
||||
|
||||
AiDecision:
|
||||
type: object
|
||||
description: |
|
||||
AI decision step: one call to a decision model (TypeSafe's Jev, or Cloudflare's
|
||||
Jev-compatible Clef) that answers typed questions about a state with calibrated
|
||||
probabilities, instead of generating text.
|
||||
Prefer it over an AI agent to classify, route, score or check something: it is fast,
|
||||
cheap and its answers are structured. Its result is { output: { <question name>: answer },
|
||||
model, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on
|
||||
the answers, follow it with a branchone whose branch exprs read
|
||||
results.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent
|
||||
supplies the state.
|
||||
properties:
|
||||
input_transforms:
|
||||
type: object
|
||||
description: Input parameters for the decision mapped to their values
|
||||
properties:
|
||||
# A provider config whose kind is 'typesafe' (model 'jev-latest' unless pinned) or
|
||||
# 'cloudflare' (model 'clef' or 'clef-flash').
|
||||
provider:
|
||||
$ref: '#/components/schemas/ProviderTransform'
|
||||
state:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
description: |
|
||||
What the questions are asked about: a string, an object or an array of strings.
|
||||
An object with descriptive keys holding only what the questions need works best.
|
||||
Example: { message: flow_input.message, plan: results.a.plan }
|
||||
questions:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
description: |
|
||||
Object mapping each question name to { type, instructions, criteria }, where type is:
|
||||
- 'choice': criteria maps each option to its description (up to 255); the answer
|
||||
has choice, probabilities and confidence
|
||||
- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer
|
||||
has score, legend, probabilities and confidence
|
||||
- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has
|
||||
noul, the probability of yes (0 to 1)
|
||||
Example: { intent: { type: 'choice', instructions: 'What does the customer want?',
|
||||
criteria: { refund: 'Money back', bug: 'Something is broken' } } }
|
||||
required:
|
||||
- provider
|
||||
- state
|
||||
- questions
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- aidecision
|
||||
tag:
|
||||
type: string
|
||||
description: Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)
|
||||
required:
|
||||
- type
|
||||
- input_transforms
|
||||
|
||||
Identity:
|
||||
type: object
|
||||
description: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -182,6 +182,91 @@ names, so neither is name-checked at all — leave those summaries as they are.
|
||||
whenever the name alone does not make that obvious; it overrides the description derived from the
|
||||
underlying script
|
||||
|
||||
## AI Decision Modules
|
||||
|
||||
An `aidecision` module asks a decision model (TypeSafe's Jev or Cloudflare's Clef) typed questions about a `state` and
|
||||
answers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step
|
||||
is a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it
|
||||
is faster, cheaper, and its answers have a fixed shape.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "triage",
|
||||
"summary": "Classify the ticket",
|
||||
"value": {
|
||||
"type": "aidecision",
|
||||
"input_transforms": {
|
||||
"provider": {
|
||||
"type": "static",
|
||||
"value": { "kind": "typesafe", "resource": "$res:f/ai/typesafe", "model": "jev-latest" }
|
||||
},
|
||||
"state": { "type": "javascript", "expr": "flow_input.message" },
|
||||
"questions": {
|
||||
"type": "static",
|
||||
"value": {
|
||||
"intent": {
|
||||
"type": "choice",
|
||||
"instructions": "What does the customer want?",
|
||||
"criteria": { "refund": "Money back", "bug": "Something is broken", "other": "Anything else" }
|
||||
},
|
||||
"urgency": {
|
||||
"type": "score",
|
||||
"instructions": "How urgent is the ticket?",
|
||||
"criteria": ["Can wait", "This week", "Today", "Right now"]
|
||||
},
|
||||
"angry": { "type": "noul", "instructions": "Is the customer angry?" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned) or `cloudflare`
|
||||
(`model` `clef`, or `clef-flash` for faster answers); the resource is of that same type
|
||||
- `state` is the content to evaluate: usually a text, such as the message to classify. To combine
|
||||
several values, pass an object with descriptive keys holding only what the questions need
|
||||
(`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works
|
||||
- `questions` maps each question name to `{ type, instructions, criteria }`:
|
||||
- `choice`: `criteria` maps each option to its description (up to 255 options). The answer has
|
||||
`choice`, `probabilities` and `confidence`
|
||||
- `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`,
|
||||
`legend`, `probabilities` and `confidence`
|
||||
- `noul`: yes/no, `criteria` optionally `{ "true": ..., "false": ... }` descriptions. The answer has
|
||||
`noul`, the probability of yes from 0 to 1
|
||||
- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step
|
||||
reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or
|
||||
`results.triage.output.angry.noul > 0.7`
|
||||
|
||||
### Branching on the Answers
|
||||
|
||||
To route on the answers, put a `branchone` right after the decision, one branch per option, with
|
||||
each `expr` reading the decision by id:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "route",
|
||||
"value": {
|
||||
"type": "branchone",
|
||||
"branches": [
|
||||
{ "summary": "refund", "expr": "results.triage.output.intent.choice === 'refund'", "modules": [...] },
|
||||
{ "summary": "bug", "expr": "results.triage.output.intent.choice === 'bug'", "modules": [...] }
|
||||
],
|
||||
"default": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For a yes/no question, one branch with `"expr": "results.triage.output.angry.noul >= 0.5"` (tune the
|
||||
threshold as needed) and the `default` as the "no" path.
|
||||
|
||||
### As an Agent Tool
|
||||
|
||||
An `aidecision` can be a `flowmodule` tool of an `aiagent` (`"tool_type": "flowmodule", "type":
|
||||
"aidecision"`): set `state` to `{ "type": "ai" }` so the calling model supplies it, and only `output`
|
||||
goes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step
|
||||
can call one, not an agent used as a tool.
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
- Missing `input_transforms` - Rawscript parameters won't receive values without them
|
||||
|
||||
Reference in New Issue
Block a user