mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: add decision output to AI agent steps, answered by TypeSafe's Jev
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
01b38320ea
commit
007197fbd6
@@ -1 +1 @@
|
||||
bc3ef08c8e4233508c023e6ee847a3cd0b8be43b
|
||||
efe51138899be4a90c85d539f1cfdc08c83a4b74
|
||||
@@ -0,0 +1,6 @@
|
||||
-- `created_by` tells this migration's row from one a user created by hand. Resources of the type
|
||||
-- are left alone: they hold credentials a user entered.
|
||||
DELETE FROM resource_type
|
||||
WHERE workspace_id = 'admins'
|
||||
AND name = 'typesafe'
|
||||
AND created_by = 'system';
|
||||
@@ -0,0 +1,33 @@
|
||||
-- The credential an AI agent step with decision output runs TypeSafe's Jev with. Seeded under
|
||||
-- 'admins' so every workspace sees it, in the shape the hub gives the other AI providers, which
|
||||
-- the step reads as `api_key` and `base_url`. A hub sync that later publishes the same name
|
||||
-- updates the schema in place.
|
||||
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, edited_at)
|
||||
VALUES (
|
||||
'admins',
|
||||
'typesafe',
|
||||
'{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"order": ["api_key", "base_url"],
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "API key from the TypeSafe dashboard.",
|
||||
"password": true,
|
||||
"nullable": false,
|
||||
"default": ""
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"description": "Optional. Only needed to overwrite the default base URL.",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"required": ["api_key"]
|
||||
}',
|
||||
'API key for TypeSafe''s Jev decision model, which answers typed questions with calibrated probabilities. Used by AI agent steps with decision output.',
|
||||
'system',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (workspace_id, name) DO NOTHING;
|
||||
@@ -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,6 +123,9 @@ pub enum AIProvider {
|
||||
CustomAI,
|
||||
#[serde(rename = "aws_bedrock")]
|
||||
AWSBedrock,
|
||||
/// TypeSafe's Jev decision model. It answers typed questions rather than chatting, so only
|
||||
/// an agent step with decision output runs it (`run_decision`), never the agent loop.
|
||||
TypeSafe,
|
||||
}
|
||||
|
||||
impl AIProvider {
|
||||
@@ -177,6 +181,7 @@ 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()),
|
||||
p @ (AIProvider::CustomAI | AIProvider::AzureOpenAI | AIProvider::AzureFoundry) => {
|
||||
Err(Error::BadRequest(format!(
|
||||
"{:?} provider requires a base URL in the resource",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Decision output: one call to TypeSafe's System One endpoint, 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::{
|
||||
credentials::ProviderCredentials,
|
||||
proxy::{common_outbound_headers, retain_effective_credentials},
|
||||
types::TokenUsage,
|
||||
utils::pinned_ai_client_for,
|
||||
};
|
||||
|
||||
/// The alias TypeSafe keeps on its current model, sent when the step names none.
|
||||
pub const TYPESAFE_DEFAULT_MODEL: &str = "jev-latest";
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SystemOneUsage {
|
||||
input_tokens: Option<i32>,
|
||||
output_tokens: Option<i32>,
|
||||
}
|
||||
|
||||
/// A decision step's result. `output` holds the answers keyed by question name, where a text
|
||||
/// 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 step, refused here rather than by the endpoint when
|
||||
/// either is missing: a flow that passes an empty expression result should read what it forgot.
|
||||
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) => None,
|
||||
Some(Value::String(s)) if s.trim().is_empty() => None,
|
||||
Some(state) => Some(state),
|
||||
}
|
||||
.ok_or_else(|| Error::BadRequest("'state' must be provided for decision output".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 decision output"
|
||||
.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 model = if model.trim().is_empty() {
|
||||
TYPESAFE_DEFAULT_MODEL
|
||||
} else {
|
||||
model
|
||||
};
|
||||
let endpoint = format!("{}/systemone", credentials.base_url.trim_end_matches('/'));
|
||||
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 TypeSafe: {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!(
|
||||
"TypeSafe error calling {endpoint}: {status} - {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to read the TypeSafe response: {e}")))?;
|
||||
let parsed = serde_json::from_str::<SystemOneResponse>(&body)
|
||||
.map_err(|e| Error::ExecutionErr(format!("Unexpected TypeSafe response ({e}): {body}")))?;
|
||||
|
||||
Ok(DecisionResult {
|
||||
output: parsed.answers,
|
||||
model: parsed.model,
|
||||
usage: parsed.usage.map(|u| {
|
||||
TokenUsage::new(
|
||||
u.input_tokens,
|
||||
u.output_tokens,
|
||||
u.input_tokens.zip(u.output_tokens).map(|(i, o)| i + o),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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 providers;
|
||||
pub mod proxy;
|
||||
|
||||
@@ -80,6 +80,10 @@ 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 expects. TypeSafe serves no chat route, and `run_agent` refuses it before a
|
||||
// chat request could be built.
|
||||
AIProvider::TypeSafe => Box::new(OtherQueryBuilder::new(AIProvider::TypeSafe)),
|
||||
_ => Box::new(OtherQueryBuilder::new(credentials.provider.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@ pub fn proxy_execution_mode(provider: &AIProvider) -> ProxyExecutionMode {
|
||||
| AIProvider::Groq
|
||||
| AIProvider::OpenRouter
|
||||
| AIProvider::TogetherAI
|
||||
| AIProvider::CustomAI => ProxyExecutionMode::HttpForward,
|
||||
| AIProvider::CustomAI
|
||||
| AIProvider::TypeSafe => ProxyExecutionMode::HttpForward,
|
||||
AIProvider::GoogleAI => ProxyExecutionMode::NativeGoogleAi,
|
||||
AIProvider::AWSBedrock => ProxyExecutionMode::NativeAwsBedrock,
|
||||
}
|
||||
@@ -443,6 +444,7 @@ mod tests {
|
||||
(AIProvider::OpenRouter, ProxyExecutionMode::HttpForward),
|
||||
(AIProvider::TogetherAI, ProxyExecutionMode::HttpForward),
|
||||
(AIProvider::CustomAI, ProxyExecutionMode::HttpForward),
|
||||
(AIProvider::TypeSafe, ProxyExecutionMode::HttpForward),
|
||||
(AIProvider::GoogleAI, ProxyExecutionMode::NativeGoogleAi),
|
||||
(AIProvider::AWSBedrock, ProxyExecutionMode::NativeAwsBedrock),
|
||||
];
|
||||
|
||||
@@ -68,6 +68,17 @@ pub enum OutputType {
|
||||
Image,
|
||||
}
|
||||
|
||||
/// What an agent step produces. Text and image run the agent loop on a chat model, as the
|
||||
/// `OutputType` they map to; a decision is typed answers to `questions` about a `state`, from one
|
||||
/// call to a decision model (TypeSafe's Jev), so it never reaches a query builder.
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StepOutputType {
|
||||
Text,
|
||||
Image,
|
||||
Decision,
|
||||
}
|
||||
|
||||
impl Default for OutputType {
|
||||
fn default() -> Self {
|
||||
OutputType::Text
|
||||
@@ -130,7 +141,9 @@ struct AIAgentArgsRaw {
|
||||
temperature: Option<f32>,
|
||||
max_completion_tokens: Option<u32>,
|
||||
output_schema: Option<OpenAPISchema>,
|
||||
output_type: Option<OutputType>,
|
||||
output_type: Option<StepOutputType>,
|
||||
state: Option<serde_json::Value>,
|
||||
questions: Option<serde_json::Value>,
|
||||
#[serde(alias = "user_images")]
|
||||
user_attachments: Option<Vec<S3Object>>,
|
||||
streaming: Option<bool>,
|
||||
@@ -158,7 +171,11 @@ pub struct AIAgentArgs {
|
||||
pub temperature: Option<f32>,
|
||||
pub max_completion_tokens: Option<u32>,
|
||||
pub output_schema: Option<OpenAPISchema>,
|
||||
pub output_type: Option<OutputType>,
|
||||
pub output_type: Option<StepOutputType>,
|
||||
/// What a decision is about: a string, an object or an array. Read only by decision output.
|
||||
pub state: Option<serde_json::Value>,
|
||||
/// The typed questions a decision answers, keyed by name. Read only by decision output.
|
||||
pub questions: Option<serde_json::Value>,
|
||||
pub user_attachments: Option<Vec<S3Object>>,
|
||||
pub streaming: Option<bool>,
|
||||
pub max_iterations: Option<usize>,
|
||||
@@ -203,6 +220,8 @@ impl From<AIAgentArgsRaw> for AIAgentArgs {
|
||||
max_completion_tokens: raw.max_completion_tokens,
|
||||
output_schema: raw.output_schema,
|
||||
output_type: raw.output_type,
|
||||
state: raw.state,
|
||||
questions: raw.questions,
|
||||
user_attachments: raw.user_attachments,
|
||||
streaming: raw.streaming,
|
||||
max_iterations: raw.max_iterations,
|
||||
|
||||
@@ -28754,6 +28754,7 @@ components:
|
||||
togetherai,
|
||||
aws_bedrock,
|
||||
customai,
|
||||
typesafe,
|
||||
]
|
||||
|
||||
GitSyncObjectType:
|
||||
|
||||
@@ -23,6 +23,7 @@ use windmill_mcp::McpClient;
|
||||
use crate::ai::tools::McpClientStub as McpClient;
|
||||
use windmill_ai::{
|
||||
ai_providers::AIProvider,
|
||||
decision::{decision_inputs, run_systemone},
|
||||
image_handler::upload_image_to_s3,
|
||||
providers::{
|
||||
create_chat_completions_query_builder, create_query_builder, is_chat_completions_only,
|
||||
@@ -121,8 +122,9 @@ fn prepare_auto_memory_messages_for_persistence(
|
||||
}
|
||||
|
||||
/// The inputs a linked step supplies for itself; the resource holds the rest of the brain.
|
||||
const FLOW_LOCAL_AGENT_KEYS: [&str; 5] = [
|
||||
const FLOW_LOCAL_AGENT_KEYS: [&str; 6] = [
|
||||
"user_message",
|
||||
"state",
|
||||
"user_attachments",
|
||||
"enabled_tools",
|
||||
"memory_id",
|
||||
@@ -713,11 +715,16 @@ 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.
|
||||
let tools = if direct_parent_job_kind == JobKind::AIAgent {
|
||||
let tools = if args.output_type == Some(StepOutputType::Decision) {
|
||||
// A decision is one call that offers no tools, so its roster is never resolved: an MCP
|
||||
// server the step still lists is not contacted, and there is nothing a narrowing could name.
|
||||
args.enabled_tools = None;
|
||||
Vec::new()
|
||||
} else if direct_parent_job_kind == JobKind::AIAgent {
|
||||
// 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.
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
@@ -1130,7 +1137,17 @@ pub async fn run_agent(
|
||||
// abort handles for spawned tool tasks
|
||||
tool_abort_handles: ToolAbortHandles,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text);
|
||||
let output_type = &match args.output_type {
|
||||
Some(StepOutputType::Decision) => return run_decision(db, conn, job, args).await,
|
||||
Some(StepOutputType::Image) => OutputType::Image,
|
||||
Some(StepOutputType::Text) | None => OutputType::Text,
|
||||
};
|
||||
if args.provider.kind == AIProvider::TypeSafe {
|
||||
return Err(Error::BadRequest(
|
||||
"TypeSafe answers decisions, not messages: set the step's output type to decision"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
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("");
|
||||
@@ -2060,6 +2077,37 @@ pub async fn run_agent(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Decision output: the step's questions answered about its state in a single call, with no loop,
|
||||
/// tools, memory or stream.
|
||||
async fn run_decision(
|
||||
db: &DB,
|
||||
conn: &Connection,
|
||||
job: &MiniPulledJob,
|
||||
args: &AIAgentArgs,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
if args.provider.kind != AIProvider::TypeSafe {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Decision output needs a TypeSafe 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;
|
||||
let result = run_systemone(
|
||||
&credentials,
|
||||
args.provider.get_model(),
|
||||
state,
|
||||
questions,
|
||||
timeout,
|
||||
)
|
||||
.await?;
|
||||
windmill_common::feature_usage::log_feature_usage("ai_agent", "decision", "run");
|
||||
Ok(to_raw_value(&result))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
Generated
+1
-1
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
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 51 registered actions across nineteen features (`ai_session`, `ai_chat`,
|
||||
It currently carries 57 registered actions across nineteen features (`ai_session`, `ai_chat`,
|
||||
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `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
|
||||
|
||||
@@ -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 { workspaceStore } from '$lib/stores'
|
||||
import ResourcePicker from './ResourcePicker.svelte'
|
||||
@@ -17,13 +22,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) in place of the chat ones, for an agent step with
|
||||
* decision output. */
|
||||
decision?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
value: _uncheckedValue = $bindable(),
|
||||
disabled = false,
|
||||
actions,
|
||||
workspace = undefined
|
||||
workspace = undefined,
|
||||
decision = false
|
||||
}: Props = $props()
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore ?? '')
|
||||
@@ -41,13 +50,15 @@
|
||||
// do not share a model list.
|
||||
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(',')
|
||||
// The resource picker offers every provider type of the mode at once and the pick is what names
|
||||
// the kind.
|
||||
let offeredProviders = $derived(Object.keys(decision ? DECISION_AI_PROVIDERS : AI_PROVIDERS))
|
||||
let providerResourceTypes = $derived(offeredProviders.join(','))
|
||||
|
||||
// Read once: only a field that holds nothing yet is given the mode's default kind.
|
||||
if (!_uncheckedValue) {
|
||||
_uncheckedValue = {
|
||||
kind: 'openai',
|
||||
kind: untrack(() => decision) ? 'typesafe' : 'openai',
|
||||
resource: '',
|
||||
model: ''
|
||||
}
|
||||
@@ -99,7 +110,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) {
|
||||
@@ -116,7 +127,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) {
|
||||
@@ -164,7 +175,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
|
||||
}
|
||||
@@ -224,7 +235,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
|
||||
@@ -235,21 +246,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 start on text output. -->
|
||||
{#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>
|
||||
|
||||
@@ -1468,7 +1468,13 @@
|
||||
{showSchemaExplorer}
|
||||
/>
|
||||
{:else if inputCat == 'ai-provider'}
|
||||
<AIProviderPicker bind:value {disabled} {actions} {workspace} />
|
||||
<AIProviderPicker
|
||||
bind:value
|
||||
{disabled}
|
||||
{actions}
|
||||
{workspace}
|
||||
decision={otherArgs?.output_type === 'decision'}
|
||||
/>
|
||||
{:else if inputCat == 'email'}
|
||||
<input
|
||||
{autofocus}
|
||||
|
||||
@@ -1085,16 +1085,17 @@
|
||||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||||
are started for, whether AI chat skills are turned on or off and how often one is
|
||||
loaded, whether an AI agent run narrows the tools it may call and whether that leaves
|
||||
it with none, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and
|
||||
change a membership, the plan tier and quota shown when the execution meter is opened,
|
||||
whether app sandbox isolation is turned on, whether a step's workspace script is
|
||||
edited from the flow editor, which skin approval steps are given, how many AI sessions
|
||||
are brought back from the workspace object storage backup, how data tables and their
|
||||
migrations are set up and used, how often an empty workspace home is seen, how often
|
||||
the home page’s create menu and hub-project picker are opened and from which entry
|
||||
point, the name of any public hub project imported from the home page and how far that
|
||||
import got, whether a pre-approved trial offer was opened, and whether data tables are
|
||||
put under roles and whether callers name a role or take the default, last 30 days)</li
|
||||
it with none, how often an AI agent step answers with a decision, whether SSO logins
|
||||
evaluate an IdP groups claim (SAML or OIDC) and change a membership, the plan tier and
|
||||
quota shown when the execution meter is opened, whether app sandbox isolation is
|
||||
turned on, whether a step's workspace script is edited from the flow editor, which
|
||||
skin approval steps are given, how many AI sessions are brought back from the
|
||||
workspace object storage backup, how data tables and their migrations are set up and
|
||||
used, how often an empty workspace home is seen, how often the home page’s create menu
|
||||
and hub-project picker are opened and from which entry point, the name of any public
|
||||
hub project imported from the home page and how far that import got, whether a
|
||||
pre-approved trial offer was opened, and whether data tables are put under roles and
|
||||
whether callers name a role or take the default, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
@@ -1152,16 +1153,17 @@
|
||||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||||
are started for, whether AI chat skills are turned on or off and how often one is
|
||||
loaded, whether an AI agent run narrows the tools it may call and whether that leaves
|
||||
it with none, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and
|
||||
change a membership, the plan tier and quota shown when the execution meter is opened,
|
||||
whether app sandbox isolation is turned on, whether a step's workspace script is
|
||||
edited from the flow editor, which skin approval steps are given, how many AI sessions
|
||||
are brought back from the workspace object storage backup, how data tables and their
|
||||
migrations are set up and used, how often an empty workspace home is seen, how often
|
||||
the home page’s create menu and hub-project picker are opened and from which entry
|
||||
point, the name of any public hub project imported from the home page and how far that
|
||||
import got, whether a pre-approved trial offer was opened, and whether data tables are
|
||||
put under roles and whether callers name a role or take the default, last 30 days)</li
|
||||
it with none, how often an AI agent step answers with a decision, whether SSO logins
|
||||
evaluate an IdP groups claim (SAML or OIDC) and change a membership, the plan tier and
|
||||
quota shown when the execution meter is opened, whether app sandbox isolation is
|
||||
turned on, whether a step's workspace script is edited from the flow editor, which
|
||||
skin approval steps are given, how many AI sessions are brought back from the
|
||||
workspace object storage backup, how data tables and their migrations are set up and
|
||||
used, how often an empty workspace home is seen, how often the home page’s create menu
|
||||
and hub-project picker are opened and from which entry point, the name of any public
|
||||
hub project imported from the home page and how far that import got, whether a
|
||||
pre-approved trial offer was opened, and whether data tables are put under roles and
|
||||
whether callers name a role or take the default, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
import { getResourceTypes } from './resourceTypesStore'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { AGENT_FIELDS, initialVisibleAgentFields } from './flows/agentFormFields'
|
||||
import {
|
||||
AGENT_FIELDS,
|
||||
agentFieldServes,
|
||||
agentOutputType,
|
||||
initialVisibleAgentFields
|
||||
} from './flows/agentFormFields'
|
||||
import { openAgentFields } from './flows/content/AiAgentStepInputs.svelte'
|
||||
|
||||
interface Props {
|
||||
@@ -74,6 +79,19 @@
|
||||
const visible = initialVisibleAgentFields(transforms, schema?.properties)
|
||||
for (const key of openAgentFields(openFieldsKey)) visible.add(key)
|
||||
for (const key of runInputKeys) visible.add(key)
|
||||
// The open fields can carry the other output type's from before a switch. Only a step's own
|
||||
// agent says which output type it runs: a linked one takes it from the agent.
|
||||
const outputTransform = transforms?.output_type as
|
||||
| { type?: string; value?: unknown }
|
||||
| undefined
|
||||
if (outputTransform) {
|
||||
const outputType = agentOutputType(
|
||||
outputTransform.type === 'static' ? outputTransform.value : undefined
|
||||
)
|
||||
for (const spec of AGENT_FIELDS) {
|
||||
if (!agentFieldServes(spec, outputType)) visible.delete(spec.key)
|
||||
}
|
||||
}
|
||||
const known = new Set(AGENT_FIELDS.map((f) => f.key))
|
||||
// Listed in the agent form's order rather than the schema's, so the two read the same.
|
||||
const position = new Map(AGENT_FIELDS.map((f, i) => [f.key, i]))
|
||||
@@ -212,6 +230,9 @@
|
||||
title={schema.properties[argName].title}
|
||||
placeholder={schema.properties[argName].placeholder}
|
||||
workspace={opWs}
|
||||
otherArgs={isAgent
|
||||
? { output_type: stepsInputArgs?.getStepInputArgs(mod.id, 'output_type') }
|
||||
: undefined}
|
||||
>
|
||||
{#snippet fieldHeaderActions()}
|
||||
{#if stepsInputArgs?.isArgManuallySet(mod.id, argName) && hasConfiguredInput(argName)}
|
||||
|
||||
@@ -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 a decision step's resource validates; `validateAiAgentProviders` keeps each kind to
|
||||
// its output 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,9 @@ 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. TypeSafe's list is
|
||||
* the ids Windmill knows, not a listing: it also serves `jev-preview` and every pinned version. */
|
||||
const ALIASING_MODEL_LISTING_KINDS: ReadonlySet<string> = new Set(['openrouter', 'typesafe'])
|
||||
|
||||
/** 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 +269,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,45 @@ describe('validateAiAgentProviders', () => {
|
||||
).not.toThrow()
|
||||
expect(warnings).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps a decision model to decision output, and decision output to it', () => {
|
||||
const typesafe: AiAgentProviderOption = {
|
||||
kind: 'typesafe',
|
||||
resourcePath: 'u/admin/typesafe',
|
||||
resourceRef: '$res:u/admin/typesafe',
|
||||
models: { ids: ['jev-latest'], complete: false },
|
||||
modelsAreLive: true,
|
||||
customEndpoint: false
|
||||
}
|
||||
const catalog = { ...COMPLETE, options: [ANTHROPIC, typesafe] }
|
||||
const step = (kind: string, resource: string, model: string, outputType?: string) => [
|
||||
{
|
||||
id: 'agent',
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
input_transforms: {
|
||||
provider: { type: 'static', value: { kind, resource, model } },
|
||||
...(outputType ? { output_type: { type: 'static', value: outputType } } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
expect(() =>
|
||||
validateAiAgentProviders(
|
||||
step('typesafe', '$res:u/admin/typesafe', 'jev-latest', 'decision'),
|
||||
catalog
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
validateAiAgentProviders(step('typesafe', '$res:u/admin/typesafe', 'jev-latest'), catalog)
|
||||
).toThrow(/only answers decisions/)
|
||||
expect(() =>
|
||||
validateAiAgentProviders(
|
||||
step('anthropic', '$res:u/admin/anthropic', 'claude-sonnet-5', 'decision'),
|
||||
catalog
|
||||
)
|
||||
).toThrow(/runs on a `typesafe` resource/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAiAgentProvidersPrompt', () => {
|
||||
@@ -120,10 +165,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 +182,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 +207,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('')
|
||||
})
|
||||
|
||||
|
||||
@@ -106,7 +106,8 @@ function describeOption(option: AiAgentProviderOption): string {
|
||||
? ' (the endpoint may also serve model names this list does not show)'
|
||||
: ''
|
||||
const modelList = models.length > 0 ? `${models.join(', ')}${more}${caveat}` : 'none listed'
|
||||
return `- \`${option.resourceRef}\` (kind \`${option.kind}\`) — models: ${modelList}`
|
||||
const decisionOnly = option.kind === 'typesafe' ? ', decision output only' : ''
|
||||
return `- \`${option.resourceRef}\` (kind \`${option.kind}\`${decisionOnly}) — models: ${modelList}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,7 +177,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
|
||||
@@ -276,6 +279,21 @@ function checkProviderValue(
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** A decision runs only on a decision model, and a decision model answers nothing else. An
|
||||
* `output_type` expression is only known at run time, so it is not checked. */
|
||||
function checkOutputTypeFits(kind: unknown, outputType: unknown): ProviderIssue | undefined {
|
||||
const decisionKind = kind === 'typesafe'
|
||||
if (decisionKind && outputType !== 'decision') {
|
||||
return blocking(
|
||||
'a `typesafe` resource only answers decisions: set output_type to "decision" and give the step state and questions, or use a chat provider'
|
||||
)
|
||||
}
|
||||
if (!decisionKind && outputType === 'decision') {
|
||||
return blocking('output_type "decision" runs on a `typesafe` resource, not a chat provider')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -297,7 +315,13 @@ 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 outputType = value.input_transforms?.output_type
|
||||
const issue =
|
||||
checkProviderValue(transform.value, known) ??
|
||||
checkOutputTypeFits(
|
||||
(transform.value as Record<string, unknown>)?.kind,
|
||||
outputType?.type === 'static' ? outputType.value : undefined
|
||||
)
|
||||
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
@@ -73,7 +73,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'>
|
||||
|
||||
export const AI_PROVIDERS: Record<ChatAIProvider, AIProviderDetails> = {
|
||||
openai: {
|
||||
label: 'OpenAI',
|
||||
defaultModels: OPENAI_MODELS
|
||||
@@ -144,6 +148,27 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
|
||||
}
|
||||
}
|
||||
|
||||
/** TypeSafe's Jev answers typed questions instead of messages, so only an agent step with decision
|
||||
* output offers it. The pinned 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']
|
||||
}
|
||||
}
|
||||
|
||||
export function isChatAIProvider(provider: string): provider is ChatAIProvider {
|
||||
return provider in AI_PROVIDERS
|
||||
}
|
||||
|
||||
/** Label and default models of any provider kind, chat or decision. */
|
||||
export function aiProviderDetails(provider: AIProvider): AIProviderDetails {
|
||||
return isChatAIProvider(provider) ? AI_PROVIDERS[provider] : DECISION_AI_PROVIDERS[provider]
|
||||
}
|
||||
|
||||
export interface ModelResponse {
|
||||
id: string
|
||||
object: string
|
||||
@@ -174,6 +199,12 @@ export async function fetchAvailableModels(
|
||||
/** Cap on the listing response, for callers that fetch without a user asking. */
|
||||
maxBytes?: number
|
||||
): Promise<string[]> {
|
||||
// TypeSafe's listing is not OpenAI-shaped (`name`, not `id`), and it serves one model under
|
||||
// aliases, so its known ids are the list.
|
||||
if (!isChatAIProvider(provider)) {
|
||||
return DECISION_AI_PROVIDERS[provider].defaultModels
|
||||
}
|
||||
|
||||
// Handle AWS Bedrock separately (needs both foundation-models and inference-profiles)
|
||||
if (provider === 'aws_bedrock') {
|
||||
const headers = {
|
||||
@@ -487,7 +518,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,
|
||||
|
||||
@@ -21,6 +21,24 @@ export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string }[] = [
|
||||
{ id: 'output', label: 'Output' }
|
||||
]
|
||||
|
||||
/** What an agent step produces, as `output_type` names it. */
|
||||
export type AgentOutputType = 'text' | 'image' | 'decision'
|
||||
|
||||
/** The output types that run the agent loop, which a field serves unless it says otherwise. */
|
||||
const LOOP_OUTPUT_TYPES: readonly AgentOutputType[] = ['text', 'image']
|
||||
const EVERY_OUTPUT_TYPE: readonly AgentOutputType[] = ['text', 'image', 'decision']
|
||||
|
||||
/** The output type an `output_type` value selects. An expression is only known once a run
|
||||
* evaluates it, so it reads as text, which is also what an absent key runs as. */
|
||||
export function agentOutputType(value: unknown): AgentOutputType {
|
||||
return value === 'image' || value === 'decision' ? value : 'text'
|
||||
}
|
||||
|
||||
/** Whether a run with this output type reads the field. */
|
||||
export function agentFieldServes(spec: AgentFieldSpec, outputType: AgentOutputType): boolean {
|
||||
return (spec.outputTypes ?? LOOP_OUTPUT_TYPES).includes(outputType)
|
||||
}
|
||||
|
||||
/** The tool roster, which reads `flowModule.value.tools` rather than an `input_transforms` key.
|
||||
* It lives in the registry so the groups keep a single ordering. */
|
||||
export const AGENT_TOOLS_ROW = 'tools'
|
||||
@@ -96,8 +114,10 @@ export interface AgentFieldSpec {
|
||||
/** What leaving the field unset does, written for a reader, shown under the field's name in the
|
||||
* add menu. */
|
||||
defaultHint?: string
|
||||
/** Ignored for image output, so the field hides while `output_type` is `'image'`. */
|
||||
textOnly?: boolean
|
||||
/** The output types whose runs read the field; it hides under any other. Unset means text and
|
||||
* image, the two that run the agent loop: a decision reads only its provider, state and
|
||||
* questions. */
|
||||
outputTypes?: readonly AgentOutputType[]
|
||||
}
|
||||
|
||||
export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
@@ -105,7 +125,8 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
key: 'provider',
|
||||
group: 'model',
|
||||
label: 'Provider',
|
||||
core: true
|
||||
core: true,
|
||||
outputTypes: EVERY_OUTPUT_TYPE
|
||||
},
|
||||
// Not text-only, unlike the fields around it: image output runs through an ordinary chat model
|
||||
// (OpenAI's `image_generation` tool, OpenRouter's `modalities`) that samples at this
|
||||
@@ -124,6 +145,15 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
tooltip: 'The most tokens the model may produce in its answer.',
|
||||
defaultHint: 'Default: the provider decides'
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
group: 'messages',
|
||||
label: 'State',
|
||||
tooltip:
|
||||
'What the questions are asked about: a text, an object or a list of texts. Name each part of an object, and send only what the questions need: detail they do not need makes the answers less accurate.',
|
||||
core: true,
|
||||
outputTypes: ['decision']
|
||||
},
|
||||
{
|
||||
key: 'user_message',
|
||||
group: 'messages',
|
||||
@@ -146,7 +176,7 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
tooltip: 'Windmill stores the conversation and sends its last messages with each request.',
|
||||
implicit: { kind: 'off' },
|
||||
defaultHint: 'Default: off',
|
||||
textOnly: true
|
||||
outputTypes: ['text']
|
||||
},
|
||||
{
|
||||
key: 'memory_id',
|
||||
@@ -155,7 +185,7 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
tooltip:
|
||||
'Conversation history id: runs with the same id share their history. Inherited uses the memory_id the run was started with: the conversation id in chat mode, or the memory_id query parameter otherwise. Without either, each run starts fresh. Custom sets the id on the step: a fixed id shares one history across all runs, an expression keeps one history per value.',
|
||||
implicit: '',
|
||||
textOnly: true
|
||||
outputTypes: ['text']
|
||||
},
|
||||
{
|
||||
key: 'previous_messages',
|
||||
@@ -164,7 +194,7 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
tooltip: 'History the flow supplies, sent between the system message and the user message.',
|
||||
implicit: [],
|
||||
defaultHint: 'Default: none',
|
||||
textOnly: true
|
||||
outputTypes: ['text']
|
||||
},
|
||||
{
|
||||
key: 'user_attachments',
|
||||
@@ -204,9 +234,19 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
group: 'output',
|
||||
label: 'Output type',
|
||||
tooltip:
|
||||
'Image output needs S3 storage on the workspace, ignores tools, and works with OpenAI, Google AI and the OpenRouter gemini-image-preview model.',
|
||||
'Image output needs S3 storage on the workspace, ignores tools, and works with OpenAI, Google AI and the OpenRouter gemini-image-preview model. Decision output answers typed questions about a state with probabilities, and runs on a TypeSafe resource.',
|
||||
implicit: 'text',
|
||||
defaultHint: 'Default: text'
|
||||
defaultHint: 'Default: text',
|
||||
outputTypes: EVERY_OUTPUT_TYPE
|
||||
},
|
||||
{
|
||||
key: 'questions',
|
||||
group: 'output',
|
||||
label: 'Questions',
|
||||
tooltip:
|
||||
'Each question by name, as { type, instructions, criteria }. choice picks one option: criteria maps each option to what it means. score places the state on a scale: criteria lists 2 to 10 levels in order. noul is yes or no: criteria can describe true and false. The result holds each answer with its probabilities under output.',
|
||||
core: true,
|
||||
outputTypes: ['decision']
|
||||
},
|
||||
{
|
||||
key: 'output_schema',
|
||||
@@ -214,7 +254,7 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
label: 'Output schema',
|
||||
tooltip: 'A JSON schema the answer has to follow.',
|
||||
defaultHint: 'Default: none',
|
||||
textOnly: true
|
||||
outputTypes: ['text']
|
||||
},
|
||||
{
|
||||
key: 'streaming',
|
||||
@@ -223,7 +263,7 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
tooltip: 'Send the answer back as it is generated, rather than once it is complete.',
|
||||
implicit: true,
|
||||
defaultHint: 'Default: on',
|
||||
textOnly: true
|
||||
outputTypes: ['text']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -281,11 +321,17 @@ export function agentFieldAppliesTo(
|
||||
*/
|
||||
export function initialVisibleAgentFields(
|
||||
args: Record<string, InputTransform | any> | undefined,
|
||||
schemaProperties: Record<string, any> | undefined
|
||||
schemaProperties: Record<string, any> | undefined,
|
||||
/** A linked step's comes from its agent, since the step holds no `output_type` of its own. */
|
||||
outputType: AgentOutputType = agentOutputType(
|
||||
args?.output_type?.type === 'static' ? args.output_type.value : undefined
|
||||
)
|
||||
): Set<string> {
|
||||
const visible = new Set<string>()
|
||||
for (const spec of AGENT_FIELDS) {
|
||||
if (!agentFieldAppliesTo(spec, schemaProperties)) continue
|
||||
if (!agentFieldAppliesTo(spec, schemaProperties) || !agentFieldServes(spec, outputType)) {
|
||||
continue
|
||||
}
|
||||
if (agentFieldIsSet(spec, args?.[spec.key])) visible.add(spec.key)
|
||||
}
|
||||
return visible
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AGENT_FIELDS, describeMemoryPolicy } from './agentFormFields'
|
||||
export const AGENT_BRAIN_KEYS = [
|
||||
'provider',
|
||||
'output_type',
|
||||
'questions',
|
||||
'system_prompt',
|
||||
'streaming',
|
||||
'memory',
|
||||
@@ -25,6 +26,7 @@ export const AGENT_BRAIN_KEYS = [
|
||||
*/
|
||||
export const AGENT_FLOW_LOCAL_KEYS = [
|
||||
'user_message',
|
||||
'state',
|
||||
'user_attachments',
|
||||
'enabled_tools',
|
||||
'memory_id',
|
||||
@@ -98,6 +100,7 @@ export function toolInputOverrides(
|
||||
export interface AIAgentConfig {
|
||||
provider?: unknown
|
||||
output_type?: string
|
||||
questions?: unknown
|
||||
system_prompt?: string
|
||||
streaming?: boolean
|
||||
memory?: unknown
|
||||
@@ -187,6 +190,8 @@ export function summarizeAgentBrain(
|
||||
value = typeof v === 'object' ? describeMemoryPolicy(v) : String(v)
|
||||
} else if (key === 'output_schema') {
|
||||
value = 'configured'
|
||||
} else if (key === 'questions' && typeof v === 'object') {
|
||||
value = Object.keys(v).join(', ')
|
||||
} else if (typeof v === 'boolean') {
|
||||
value = v ? 'on' : 'off'
|
||||
} else if (typeof v === 'object') {
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
flowPath = '',
|
||||
fromAgentEditor = false,
|
||||
chatInputEnabled = false,
|
||||
linkedMemory = $bindable()
|
||||
linkedBrain = $bindable()
|
||||
}: {
|
||||
agent: string | undefined
|
||||
inputTransforms: Record<string, InputTransform>
|
||||
@@ -76,8 +76,8 @@
|
||||
// is the wrong way in.
|
||||
fromAgentEditor?: boolean
|
||||
chatInputEnabled?: boolean
|
||||
// The linked agent's memory once its config has loaded, for the step's history inputs.
|
||||
linkedMemory?: { memory: unknown } | undefined
|
||||
// The linked agent's memory and output type once its config has loaded, for the step's inputs.
|
||||
linkedBrain?: { memory: unknown; output_type: unknown } | undefined
|
||||
} = $props()
|
||||
|
||||
let ws = $derived(opWorkspace ?? $workspaceStore)
|
||||
@@ -219,7 +219,9 @@
|
||||
loadedInfo?.ws === ws && loadedInfo?.path === agent ? loadedInfo : undefined
|
||||
)
|
||||
$effect(() => {
|
||||
linkedMemory = linkedInfo ? { memory: linkedInfo.config?.memory } : undefined
|
||||
linkedBrain = linkedInfo
|
||||
? { memory: linkedInfo.config?.memory, output_type: linkedInfo.config?.output_type }
|
||||
: undefined
|
||||
})
|
||||
let inheritedTools = $derived(linkedInfo?.tools ?? [])
|
||||
let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config))
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
AGENT_TOOLS_ROW,
|
||||
AGENT_FIELD_GROUPS,
|
||||
agentFieldAppliesTo,
|
||||
agentFieldServes,
|
||||
agentOutputType,
|
||||
agentMemoryMode,
|
||||
historyInputApplies,
|
||||
type AgentMemoryMode,
|
||||
@@ -112,9 +114,10 @@
|
||||
onDeleteTool?: (toolId: string) => void
|
||||
/** Where the tool picker's popover belongs, for a surface that is not the flow editor. */
|
||||
toolPickerPortal?: string
|
||||
/** A linked agent's memory, once its config has loaded: whether it keeps managed memory decides
|
||||
* which history inputs the step offers. */
|
||||
linkedMemory?: { memory: unknown } | undefined
|
||||
/** A linked agent's memory and output type, once its config has loaded: whether it keeps
|
||||
* managed memory decides which history inputs the step offers, and its output type which of
|
||||
* the flow-local inputs a run reads. */
|
||||
linkedBrain?: { memory: unknown; output_type: unknown } | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -145,7 +148,7 @@
|
||||
onAddTool = undefined,
|
||||
onDeleteTool = undefined,
|
||||
toolPickerPortal = undefined,
|
||||
linkedMemory = undefined
|
||||
linkedBrain = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
@@ -189,7 +192,7 @@
|
||||
? agentMemoryMode(transform?.value)
|
||||
: undefined
|
||||
}
|
||||
return linkedMemory ? agentMemoryMode(linkedMemory.memory) : undefined
|
||||
return linkedBrain ? agentMemoryMode(linkedBrain.memory) : undefined
|
||||
})
|
||||
|
||||
// The one-of field rewrites a value that matches none of its options, so a legacy kind the step
|
||||
@@ -250,7 +253,7 @@
|
||||
? args?.memory?.type === 'static'
|
||||
? args.memory.value
|
||||
: undefined
|
||||
: linkedMemory?.memory
|
||||
: linkedBrain?.memory
|
||||
)
|
||||
return onThisForm
|
||||
? `Ignored while memory is set to ${label}.`
|
||||
@@ -282,10 +285,10 @@
|
||||
}
|
||||
|
||||
let outputType = $derived.by(() => {
|
||||
if (!('output_type' in schemaProperties)) return agentOutputType(linkedBrain?.output_type)
|
||||
const transform = args?.['output_type']
|
||||
return transform && transform.type === 'static' ? transform.value : undefined
|
||||
return agentOutputType(transform?.type === 'static' ? transform.value : undefined)
|
||||
})
|
||||
let imageOutput = $derived(outputType === 'image')
|
||||
|
||||
// Which fields have a row. Never derived from `args`, or emptying a textbox would make its row
|
||||
// vanish under the cursor: this only ever grows, and the x is the one thing that shrinks it.
|
||||
@@ -298,7 +301,7 @@
|
||||
// A field set from anywhere else — an undo, a schema that arrived late — brings its row back on
|
||||
// its own.
|
||||
$effect(() => {
|
||||
const set = initialVisibleAgentFields(args, schemaProperties)
|
||||
const set = initialVisibleAgentFields(args, schemaProperties, outputType)
|
||||
untrack(() => {
|
||||
for (const key of set) visible.add(key)
|
||||
})
|
||||
@@ -332,7 +335,7 @@
|
||||
|
||||
function rowsIn(group: AgentFieldGroup): AgentFieldSpec[] {
|
||||
return scopedFields.filter(
|
||||
(spec) => spec.group === group && isShown(spec.key) && !(imageOutput && spec.textOnly)
|
||||
(spec) => spec.group === group && isShown(spec.key) && agentFieldServes(spec, outputType)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -342,7 +345,7 @@
|
||||
!spec.core &&
|
||||
!spec.virtual &&
|
||||
!isShown(spec.key) &&
|
||||
!(imageOutput && spec.textOnly) &&
|
||||
agentFieldServes(spec, outputType) &&
|
||||
// Memory id's row appears on its own when it is offered, so the menu never adds it.
|
||||
spec.key !== 'memory_id' &&
|
||||
!(isHistoryKey(spec.key) && !historyInputApplies(spec.key, memoryMode))
|
||||
|
||||
@@ -291,8 +291,8 @@
|
||||
}
|
||||
let inputTransformSchemaForm: { setArgs: (nargs: Record<string, any>) => void } | undefined =
|
||||
$state(undefined)
|
||||
// The linked agent's memory, which decides which history inputs the step offers.
|
||||
let linkedAgentMemory: { memory: unknown } | undefined = $state(undefined)
|
||||
// The linked agent's memory and output type, which decide which inputs the step offers.
|
||||
let linkedAgentBrain: { memory: unknown; output_type: unknown } | undefined = $state(undefined)
|
||||
|
||||
let reloadError: string | undefined = $state(undefined)
|
||||
async function reload(flowModule: FlowModule) {
|
||||
@@ -1156,7 +1156,7 @@
|
||||
opWorkspace={opWs}
|
||||
flowPath={$pathStore}
|
||||
fromAgentEditor={agentEditorHost?.() != undefined}
|
||||
bind:linkedMemory={linkedAgentMemory}
|
||||
bind:linkedBrain={linkedAgentBrain}
|
||||
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
|
||||
bind:agent={
|
||||
() =>
|
||||
@@ -1239,7 +1239,7 @@
|
||||
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
|
||||
workspace={opWs}
|
||||
visibilityKey={agentFieldsKey}
|
||||
linkedMemory={agentLinked ? linkedAgentMemory : undefined}
|
||||
linkedBrain={agentLinked ? linkedAgentBrain : undefined}
|
||||
tools={agentLinked
|
||||
? getLinkedAgentTools(
|
||||
linkedToolsScope(opWs, $pathStore),
|
||||
|
||||
@@ -24,6 +24,8 @@ export function memoryOptionLabel(memory: any): string | undefined {
|
||||
return memory?.kind ? MEMORY_OPTION_LABELS[memory.kind] : undefined
|
||||
}
|
||||
|
||||
const TEXT_OUTPUT_ONLY = "fields.output_type !== 'image' && fields.output_type !== 'decision'"
|
||||
|
||||
export const AI_AGENT_SCHEMA: Schema = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
properties: {
|
||||
@@ -34,17 +36,33 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
output_type: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Whether the answer is text or an image. An image needs S3 storage on the workspace, and ignores tools.',
|
||||
enum: ['text', 'image'],
|
||||
'Whether the answer is text, an image or a decision. An image needs S3 storage on the workspace, and ignores tools. A decision answers typed questions with probabilities, on a TypeSafe resource.',
|
||||
enum: ['text', 'image', 'decision'],
|
||||
default: 'text'
|
||||
},
|
||||
// Untyped, so the static editor takes any JSON: TypeSafe reads a string, an object or an
|
||||
// array of strings. No `showExpr`: a linked step holds no `output_type` to evaluate one
|
||||
// against, and a hidden field is cleared. `agentFieldServes` decides where it shows.
|
||||
state: {
|
||||
type: undefined,
|
||||
description:
|
||||
'What the questions are asked about: a text, an object or a list of texts. Name each part of an object, and send only what the questions need.'
|
||||
},
|
||||
questions: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Each question by name, as { type, instructions, criteria }. The type is choice (criteria maps each option to what it means), score (criteria lists 2 to 10 levels in order) or noul (yes or no, criteria can describe true and false).',
|
||||
showExpr: "fields.output_type === 'decision'"
|
||||
},
|
||||
user_message: {
|
||||
type: 'string',
|
||||
description: 'The message sent to the agent as the user turn.'
|
||||
description: 'The message sent to the agent as the user turn.',
|
||||
showExpr: "fields.output_type !== 'decision'"
|
||||
},
|
||||
system_prompt: {
|
||||
type: 'string',
|
||||
description: 'Sets how the agent behaves. Sent ahead of everything else.',
|
||||
showExpr: "fields.output_type !== 'decision'",
|
||||
// The one field people write paragraphs into, so it opens as a text area.
|
||||
minRows: 5,
|
||||
placeholder:
|
||||
@@ -54,7 +72,7 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
type: 'boolean',
|
||||
description: 'Stream the answer as it is produced.',
|
||||
default: true,
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
showExpr: TEXT_OUTPUT_ONLY
|
||||
},
|
||||
memory: {
|
||||
type: 'object',
|
||||
@@ -90,13 +108,13 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
required: ['kind', 'context_length']
|
||||
}
|
||||
],
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
showExpr: TEXT_OUTPUT_ONLY
|
||||
},
|
||||
memory_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Names the memory this step reads and writes, overriding the memory id the run was started with. Read only while managed memory is on, and not at all by an older auto or manual memory.',
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
showExpr: TEXT_OUTPUT_ONLY
|
||||
},
|
||||
previous_messages: {
|
||||
type: 'array',
|
||||
@@ -138,13 +156,13 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
},
|
||||
required: ['role']
|
||||
},
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
showExpr: TEXT_OUTPUT_ONLY
|
||||
},
|
||||
output_schema: {
|
||||
type: 'object',
|
||||
description: 'A JSON schema the answer has to follow.',
|
||||
format: 'json-schema',
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
showExpr: TEXT_OUTPUT_ONLY
|
||||
},
|
||||
user_attachments: {
|
||||
type: 'array',
|
||||
@@ -152,7 +170,8 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
items: {
|
||||
type: 'object',
|
||||
resourceType: 's3object'
|
||||
}
|
||||
},
|
||||
showExpr: "fields.output_type !== 'decision'"
|
||||
},
|
||||
// The step's own roster fills `items.enum` in, so the static editor offers the tools this
|
||||
// agent actually has (`AiAgentStepInputs`). Absence, not an empty list, is what carries every
|
||||
@@ -166,20 +185,24 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
description: 'Which of the agent tools a run may call.',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
showExpr: "fields.output_type !== 'decision'"
|
||||
},
|
||||
max_completion_tokens: {
|
||||
type: 'number',
|
||||
description: 'The most tokens the answer may use.'
|
||||
description: 'The most tokens the answer may use.',
|
||||
showExpr: "fields.output_type !== 'decision'"
|
||||
},
|
||||
temperature: {
|
||||
type: 'number',
|
||||
description: 'How random the generation is, from 0 for deterministic up to 2.'
|
||||
description: 'How random the generation is, from 0 for deterministic up to 2.',
|
||||
showExpr: "fields.output_type !== 'decision'"
|
||||
},
|
||||
max_iterations: {
|
||||
type: 'number',
|
||||
description: 'How many times the agent may loop over calling the model and running tools.',
|
||||
default: 10
|
||||
default: 10,
|
||||
showExpr: "fields.output_type !== 'decision'"
|
||||
}
|
||||
},
|
||||
// `output_type` defaults to text on the backend, so leaving it unset is valid: the form drops
|
||||
@@ -189,6 +212,8 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
order: [
|
||||
'provider',
|
||||
'output_type',
|
||||
'state',
|
||||
'questions',
|
||||
'user_message',
|
||||
'system_prompt',
|
||||
'streaming',
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import type { FlowNodeState } from '$lib/components/graph'
|
||||
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
|
||||
import { getGraphContext } from '$lib/components/graph/graphContext'
|
||||
import { agentOutputType } from '$lib/components/flows/agentFormFields'
|
||||
|
||||
interface Props {
|
||||
moduleId: string
|
||||
@@ -76,6 +77,17 @@
|
||||
menuItems = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// What an agent step is called until it is given a summary: a decision makes no agent loop.
|
||||
let agentLabel = $derived.by(() => {
|
||||
if (mod.value.type !== 'aiagent') return undefined
|
||||
if (mod.value.agent) return mod.value.agent
|
||||
const outputType = mod.value.input_transforms?.output_type
|
||||
return agentOutputType(outputType?.type === 'static' ? outputType.value : undefined) ===
|
||||
'decision'
|
||||
? 'AI decision'
|
||||
: 'AI Agent'
|
||||
})
|
||||
|
||||
const { selectionManager, moveManager } = getGraphContext()
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
|
||||
@@ -278,7 +290,7 @@
|
||||
{...itemProps}
|
||||
{nodeState}
|
||||
label={mod.summary ||
|
||||
(mod.value.type === 'aiagent' ? (mod.value.agent ?? 'AI Agent') : undefined) ||
|
||||
agentLabel ||
|
||||
(mod.id === 'preprocessor'
|
||||
? 'Preprocessor'
|
||||
: mod.id.startsWith('failure')
|
||||
|
||||
+27
-1
@@ -473,6 +473,7 @@ components:
|
||||
- togetherai
|
||||
- customai
|
||||
- aws_bedrock
|
||||
- typesafe
|
||||
|
||||
ProviderConfig:
|
||||
type: object
|
||||
@@ -1061,7 +1062,32 @@ components:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
description: |
|
||||
Output format type.
|
||||
Valid values: 'text' (default) - plain text response, 'image' - image generation
|
||||
Valid values: 'text' (default) - plain text response, 'image' - image generation,
|
||||
'decision' - typed answers to `questions` about `state` from a decision model
|
||||
(TypeSafe's Jev, with a `typesafe` provider). A decision makes one call and reads
|
||||
only provider, state and questions: no tools, memory, messages or streaming.
|
||||
state:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
description: |
|
||||
Decision output only. What the questions are asked about: a string, an object
|
||||
or an array of strings. An object with descriptive keys works best.
|
||||
Example: { message: flow_input.message, plan: results.a.plan }
|
||||
questions:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
description: |
|
||||
Decision output only. 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 is
|
||||
the probability noul (0 to 1)
|
||||
The step's result is { output: { <name>: answer }, model, usage }.
|
||||
Example: { intent: { type: 'choice', instructions: 'What does the customer want?',
|
||||
criteria: { refund: 'Money back', bug: 'Something is broken' } } }
|
||||
user_message:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
|
||||
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
Reference in New Issue
Block a user