Files
windmill/backend/windmill-worker/src/ai_executor.rs
T
c1b59f70dd feat(ai-agent): add compaction memory that summarizes older context (#10928)
* feat(ai-agent): add autocompacted memory that summarizes older context

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): compact on final-answer turns and count what a turn appended

Address the pre-push review findings on the compaction path:

- A turn the model answers without a tool call left the agent loop on its first
  iteration, so a chat-shaped step never compacted and reloaded the whole
  conversation on every later turn. Compaction now also runs after the loop.
- The trigger measured only the last request, so a single large tool result
  could carry the next one past the window without ever crossing 80%.
- The summarization call re-sent the usage-tracking request shape on endpoints
  the loop had already learned to drop it for.
- The flat 8000-token summary reserve swallowed the whole target on a small
  context window, leaving one message in the tail and summarizing the rest.
- A response cut off inside the <analysis> scratchpad was accepted as a summary.
- The chat-mode memory default was a shared object the step form edited in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): keep Anthropic prompt counts and compact once per response

Address the first CI review round on the compaction path:

- Anthropic's streaming parser dropped `message_start`, the only event carrying
  the prompt-side counts, so a native Anthropic run reported no input tokens at
  all and compaction fell back to a character estimate.
- A loop that exits without issuing another request — a structured-output turn
  does — reached the post-loop pass still holding the previous measurement and
  compacted a second time, or retried a failure with nothing changed.
- The summarization call inherited the step's `max_completion_tokens`; a low one
  truncates the summary inside its scratchpad, which counts as a failure and
  disables compaction after three of them.
- A fired trigger that found nothing to summarize said nothing.
- Memory already over the window — a lowered `context_window`, or a step moved
  over from `auto` — had no way back, since compaction only ran after an
  accepted request. It now also runs once before the first one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): state the summary's own completion cap and drop the pre-flight pass

- The summarization call asked for no completion cap at all, which is "uncapped"
  only on the OpenAI-shaped providers: Anthropic substitutes 64000, over several
  Claude models' output ceiling, and Bedrock leaves the model's own small default,
  short enough to cut the response off inside its scratchpad. It now asks for the
  reserve the split already set aside, raised to the step's cap when that is larger.
- Compaction no longer runs before the first request. The fallbacks the loop learns
  from a rejection are not known that early, so on exactly the endpoints that need
  them the summarization was malformed by construction: it failed, spent a strike,
  and the first agent request still carried the oversized conversation. A memory
  already past the window is repaired on the turn after a request the endpoint
  accepts, rather than by a pass that cannot succeed there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): ask the summary for exactly the room the split reserved

The split scales its reserve down on a small window while the request asked for
a flat 8000, so the two diverged below an 80k window: on a 4k/8k model the cap
alone exceeded the window and every summarization was refused, and on a 20k one
a full-length summary could land the conversation back over the trigger and
compact its own previous summary on the next response. Both now read one
`summary_reserve_tokens`.

The call also no longer inherits the step's reasoning effort. Every provider
counts thinking against that same budget, so a high-effort model could spend the
whole reserve before writing anything and return a summary cut off inside its
scratchpad; the compaction prompt asks for an `<analysis>` block, which is the
reasoning this call needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): charge the compaction budget for tools and the system prompt

The tail budget was the whole target, but a request also carries the system
prompt compaction keeps and the tool definitions, which are not in the message
list at all. On a small window those are most of it: a tail sized to the full
target left the next request back over the trigger, compacting again every
response, and the no-usage estimate missed the tool schemas entirely so it could
fail to trigger at all. Both now account for them.

The reserve also gains a floor. It is the summary's output cap as well as the
room the split leaves, and scaled down without one a small window gave a
structured nine-section summary a few hundred tokens — truncated inside its
scratchpad every time, which is discarded, which switches the mode off after
three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): count Gemini's tool-use prompt tokens in an agent step's usage

Gemini splits a tool-using turn's input across `promptTokenCount` and a disjoint
`toolUsePromptTokenCount`, and its thinking apart from `candidatesTokenCount`.
The agent step's parser read only the headline fields, so every tool-using turn
under-reported both — and the compaction trigger, which runs off the reported
prompt, could not see the tool results that grew it. It now goes through the same
helpers the proxy path already used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): calibrate the compaction estimate against the measured prompt

Two rounds running, the finding was "the character estimate cannot see input X"
— tool schemas, then S3 attachments, which are short paths in the message list
and whole images by the time a provider counts them. Enumerating those is a list
that only grows, so the estimate is now scaled to the one number that is ground
truth: what the provider charged for the last request. Attachments, tokenizer
drift and whatever comes next fall out of that, because the estimate is only
ever used relative to itself.

Also stop the Gemini helpers turning an absent count into `Some(0)`. Downstream,
absent means "fall back to estimating the conversation" while zero reads as an
empty prompt and would hold the trigger below its threshold for the whole run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): charge attachments what they cost and let a heavy short prefix compact

The calibration conserved the conversation's total cost but spread it by
character count, so an attachment — a short S3 path in the message list, a whole
image or PDF once a provider expands it — was charged to the text messages around
it and stayed nearly free in the split. It now carries a nominal cost of its own,
which the calibration corrects a residual on rather than the whole gap.

The four-message minimum also refused exactly the case that fix is for: an
attachment arriving on the first or second turn can pass the trigger before four
removable messages exist, and summarizing even one of them saves most of the
prompt. A prefix worth a quarter of the window is now enough on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): never summarize a prefix holding only a previous summary

The message-count floor was carrying a second job: a fresh summary sits in a one
or two message prefix, so requiring four declined it. The share threshold added
last commit admits it, and a summary is reserve-sized by construction — so the
post-compaction shape could spend one summarization per response swapping a
summary for another the same size, shrinking nothing and losing fidelity each
time. A previous summary no longer counts towards that threshold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): take the context window from the model and drop the estimate calibration

Brings compaction in line with how the AI session does the same job, which had
already answered these three questions.

- The window is looked up from the model. `MODEL_CONTEXT_WINDOWS` in
  `windmill-ai/src/model_context.rs` mirrors the session's table in
  `copilot/modelConfig.ts`, entry for entry and with the same matching rules;
  each side points at the other, since a model added to one and not the other
  compacts at two different sizes. A step's `context_window` becomes the
  override for what the lookup cannot serve, and chat mode writes none.
- Provider usage is normalized where the provider's quirk is, not at the
  consumer. `TokenUsage::with_cache_beside_input` raises `input_tokens` to the
  whole prompt for Anthropic and Bedrock, which report their cached prefix
  beside it; the OpenAI shape already counts it inside. `prompt_tokens()` is
  then just `input_tokens`, rather than inferring the shape from whether a
  write count is present.
- The estimator is no longer calibrated against the measured prompt. The
  session uses the provider's count when it has one and a chars/4 estimate
  otherwise, with nothing in between, and a tail sized a little wrong only
  compacts again a turn later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* feat(ai-agent): summarize memory down to what the database can store

Without an instance object store, memory is a 100KB database row cut
from its oldest message, the summary included, so compaction on a
mainstream model never got to keep anything across runs. A step that
persists there now runs its post-loop compaction pass against the
smaller of the model's window and the cap at chars/4, about 25k tokens:
the loop keeps the whole window, and what is written is a summary plus a
tail that fits. The run logs when that pass summarizes, and how many
messages the write dropped when one still overshoots.

The editor's storage warning on the option is removed: nothing exposes
the instance storage to it, so it keyed on the workspace S3 setting,
which is unrelated to where memory goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): get a complete, billed summary out of every provider

Compaction against the real providers turned up four things the stub
could not: Gemini and OpenAI's reasoning models think by default and
bill it against the same cap the summary must fit in, so the
summarization request now asks them for their least (none, low); an
OpenAI Responses call that hits max_output_tokens ends in
response.incomplete, whose usage the parser dropped, so that
summarization went unbilled; a summary that quotes </summary> when it
describes its own instruction was cut off at the quote, on the agent
step and the AI session alike; and the prefix could end on an unanswered
user message, after which the instruction reads as part of that turn
(Anthropic merges the two outright). The tail now starts on a user
message, and both prompts tell the model the instruction is not part of
the conversation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): compact down to half the window, on the agent step and the AI session

The gap between the 80% trigger and the target is what one compaction
buys, and every summarization request carries most of the window. At a
70% target a 128k model summarized about 13k tokens of prefix for a
summary of up to 8k, so each ~100k-token request bought a few turns of
room before the next one re-summarized the previous summary. At 50% the
same request frees about 30k.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): drop the workspace-S3 memory hint and state the database bound in the tooltip

The memory field warned that memory is kept in the database whenever the
workspace had no S3 storage. That setting has no bearing on where memory
goes: the instance object store decides, and nothing exposes it to the
editor. The field's tooltip now describes both memory kinds and states
the database bound unconditionally; the run log says what happened.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): send the summarizer its tool history as text

The summarization request carries no tool definitions, and Bedrock's
Converse API rejects toolUse/toolResult blocks that arrive without them,
so on Bedrock every summarization of a prefix holding a tool call failed
silently until the breaker tripped. The prefix's tool calls and results
now reach the summarizer rendered as text, on the agent step and in the
AI session's compaction, which goes through the same proxy.

Also drops the TokenUsage::prompt_tokens accessor, which had become a
plain read of the normalized input_tokens, and shortens the context
window field's description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): price attachments from the provider count, bound storage in bytes, effort per pro model

Addresses two Codex rounds and a leftovers audit.

- Attachments were priced at a flat 1500 tokens in the split, so a
  multi-page PDF (tens of thousands of tokens to the provider, a short
  S3 path in the message list) could be kept in the tail or leave no
  prefix worth summarizing. They are now priced from the provider's
  count for the request that carried them, less that request's text,
  with the 1500 floor where nothing was counted.
- The database storage bound measured the provider's token count, but
  the 100KB cap is bytes and repetitive text packs several characters
  per token. The persist pass now measures the serialized conversation.
- The summarizer forced `low` on every reasoning model, which the pro
  variants reject (gpt-5-pro takes only high, gpt-5.2-pro starts at
  medium); they now get no effort.
- Dropped the unused prompt_tokens accessor and its orphaned assert, an
  unused PartialEq, a needlessly public lookup, and fully-qualified
  Gemini calls; refreshed stale comments and the memory_id schema doc;
  regenerated the flow schema artifacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): evict a heavy attachment into the summarized prefix, not the tail

Pricing attachments from the provider count was not enough on its own: a
leading attachment is a user message, and the boundary rule pulled the
last unanswered user turn back into the kept tail to keep it with its
answer. For a heavy attachment that dragged it into the tail — or, at
the front, emptied the prefix — so it was never summarized and rode
every request. The boundary now moves forward instead, keeping that
user turn and its answer in the summarized prefix. Verified on the
running instance: a 25k-token PDF on a 30k window is summarized out on
the turn it overflows, and later turns drop from 26k to ~1.5k tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): keep the forward boundary move off tool results and the prefix start

The forward move that keeps an unanswered user turn out of the tail had
two edges the third Codex round found: advancing past the user could
land the boundary on a tool result (its tool_calls then summarized away,
orphaning it), and with no system prompt the summarizable prefix starts
at 0, so a trigger firing while the tail estimate fit everything indexed
below the start and panicked the task. The forward scan now skips
tool-opening boundaries, and the move is guarded above the prefix start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): drop the step temperature from the summary request

OpenAI's reasoning models (gpt-5-mini, gpt-5.1, gpt-5.2) reject
`temperature` alongside any reasoning effort but their own default, so a
step configured with a temperature made every summarization fail once
the summarizer forced a low effort — history then grew unchecked. The
internal summary call now omits the step's temperature: a structured
extraction does not need a set one, and omitting it sidesteps each
provider's temperature-versus-reasoning rules. Confirmed against the API
that low + temperature is refused on those models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): compact an oversized loaded memory before the first request

Compaction was reactive, taken only after a request the endpoint
accepted, so the fallbacks the loop learns from a rejection are known
first. But a memory loaded from an earlier run can already exceed this
run's window — the step was switched to a smaller model, or a run under
a wider one persisted more than fits — and that first request then
overflows and fails the run, with every retry reloading the same
history and failing again. A pass is now taken up front, off the
character estimate, before the first request. It uses the default
request shape; an endpoint needing a fallback may reject this one
summary, which is non-fatal, and mainstream providers need none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): under the storage bound, trigger on the max of bytes and model tokens

The storage-bound pass measured only the serialized row size, so an
attachment — a few bytes as an S3 path but nearly the whole model
context — read as tiny and the pass skipped a compaction the model
needed. It now takes the larger of the byte measure and the model's
token count, since repetitive text is few tokens but many bytes and an
attachment is the reverse; either being over must fire a pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): drop oldest turns when a summary cannot fit the window, as the AI session does

An oversized loaded memory (a step switched to a smaller model, or an
object-store run that persisted more than a later model's window holds)
left a prefix larger than the summarizer's own window, so the summary
request overflowed and failed, the memory was untouched, and every
retry failed the same way. The AI session handles this by falling back
from summarization to dropping the oldest turns down to the target;
compaction here now does the same. When a summary cannot run — it
failed, the breaker is tripped, or nothing is worth folding — the oldest
turns are dropped until the conversation fits and opens on a user
message, keeping the newest turn. The next request then always fits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): drop whole turns only, keep the storage pass to bytes, refresh the count after a rewrite

Three edges the seventh Codex round found, all in the drop-oldest
fallback and the storage-bound measure:

- drop_oldest_to_fit dropped to any point that freed enough, which could
  strand a tool result whose tool_calls went with the messages before
  it. It now drops whole turns only, always landing the boundary on a
  user message and never splitting the newest turn; a lone turn too big
  for the window is left whole rather than broken.
- The storage-bound pass measured the whole model prompt against the
  shrunk 25k window, so a large tool roster and the system prompt —
  neither written to the row — tripped it on a conversation the row
  easily held. It measures the serialized bytes alone now; the model's
  own window is enforced by the in-loop passes and the pre-first-request
  pass, so the persisted size is all this pass is for.
- A compaction rewrites the message list, so the provider's count for
  the request that produced it no longer lines up. The count is now
  cleared after any pass that rewrites the conversation, so a later pass
  measures the estimate over the actual messages instead of a stale,
  larger prompt (which could decline a summary that already fit and then
  drop it). The step temperature, no longer sent to the summarizer on
  any path, is dropped from the request struct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): measure only the persisted messages against the storage cap

Persistence strips the system prompt before writing the memory row, but
the storage pass was serializing every message including it, so a large
system prompt with a tiny conversation reported far over the storage
trigger, and the fallback dropped the one real turn, run after run. The
storage measure now serializes only the non-system messages, matching
what the row actually holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix(ai-agent): run the model-window pass before the storage-bytes pass post-loop

A turn the model answered without a tool call broke before the in-loop
compaction check, so on database-backed memory its only pass was the
storage one, which measures bytes. An attachment fills the model context
but is a few bytes in the row, so that turn never compacted and a
follow-up could overflow the model. The post-loop now runs a
model-window pass first, off the provider's count, then the
storage-bytes pass when the row is smaller than the model — both limits
enforced for a chat-shaped step, not just the one that happens to bind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix: simplify agent compaction and preserve execution history

* fix: remove unused compaction history setting

* fix: preserve answers and recover rejected agent context

* refactor: make agent compaction transactional

* fix: skip agent summaries that cannot fit retained context

* fix: explain skipped agent context compaction

* fix: retain recent agent memory when storage compaction cannot fit

* fix: start retained agent memory at a user turn

* fix: reject unsafe agent memory truncation on storage fallback

* docs: clarify agent context window override scope

* fix: keep recent turns verbatim when compaction memory outgrows storage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix: keep the compaction summary out of the agent's answers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* fix: shorten the agent context window help text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViJyjUmidDYV2m6ifQdLeH

* chore: update ee-repo-ref to 942d4013f36edac1fc9a9addbdb02198db1c7a05

This commit updates the EE repository reference after PR #812 was merged in windmill-ee-private.

Previous ee-repo-ref: 8ca1682ce6106ba6ea96894fbe606dac64102eb6

New ee-repo-ref: 942d4013f36edac1fc9a9addbdb02198db1c7a05

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-29 19:01:53 +02:00

3177 lines
129 KiB
Rust

use crate::ai::compaction::{
memory_within_capacity, persisted_bytes, AgentHistory, CompactionRequest, CompactionTrigger,
Compactor,
};
use crate::ai::tools::{execute_tool_calls, ToolAbortHandles, ToolExecutionContext};
use crate::ai::utils::{
add_message_to_conversation, any_tool_needs_previous_result, cleanup_mcp_clients,
filter_schema_by_input_transforms, find_unique_tool_name, get_flow_context,
get_flow_job_runnable_and_raw_flow, get_step_name_from_flow, load_mcp_tools,
parse_raw_script_schema, update_flow_status_module_with_actions,
update_flow_status_module_with_actions_success,
};
use crate::memory_common::MAX_MEMORY_SIZE_BYTES;
use crate::memory_oss::{memory_storage_capacity_bytes, read_from_memory, write_to_memory};
use crate::worker_flow::{get_previous_job_result, get_transform_context};
use async_recursion::async_recursion;
use regex::Regex;
use serde_json::value::RawValue;
use sha2::Digest;
use std::{collections::HashMap, sync::Arc};
use uuid::Uuid;
#[cfg(feature = "bedrock")]
use windmill_ai::ai_bedrock::check_env_credentials;
#[cfg(feature = "mcp")]
use windmill_mcp::McpClient;
#[cfg(not(feature = "mcp"))]
use crate::ai::tools::McpClientStub as McpClient;
use windmill_ai::{
ai_providers::AIProvider,
image_handler::upload_image_to_s3,
model_context::model_context_window,
providers::{
create_chat_completions_query_builder, create_query_builder, is_chat_completions_only,
openai::{
is_reasoning_summary_unavailable, rejects_reasoning_summary,
remember_reasoning_summary_unavailable,
},
remember_chat_completions_only,
},
proxy::{
common_outbound_headers, needs_unavailable_oauth_exchange, retain_effective_credentials,
},
query_builder::{BuildRequestArgs, ParsedResponse},
types::*,
utils::{pinned_ai_client_for, should_use_structured_output_tool},
};
use windmill_common::{
cache,
client::AuthedClient,
db::DB,
error::{self, Error},
flow_conversations::{memory_key, MessageExtras, MessageType},
flow_status::AgentAction,
flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue},
get_latest_hash_for_path,
jobs::JobKind,
scripts::get_full_hub_script_by_path,
utils::{StripPath, HTTP_CLIENT},
worker::{to_raw_value, Connection},
};
use windmill_queue::{append_logs, cancel_single_job, CanceledBy, MiniPulledJob};
use crate::{
ai::stream_event_processor::StreamEventProcessor,
common::{
build_args_map, resolve_job_timeout, transform_json_value, OccupancyMetrics, StreamNotifier,
},
handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome},
};
lazy_static::lazy_static! {
static ref TOOL_NAME_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_]+$").unwrap();
static ref AI_AGENT_TOOL_SCHEMA: Box<RawValue> = to_raw_value(&serde_json::json!({
"type": "object",
"properties": {
"user_message": { "type": "string" },
},
"required": ["user_message"],
"additionalProperties": false,
}));
}
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
const HARD_MAX_AGENT_ITERATIONS: usize = 1000;
/// Partial failures retain action metadata through `Message`: serializing raw
/// `OpenAIMessage` values would strip the tool names and IDs the viewer needs.
#[derive(serde::Serialize)]
struct AgentPartialResult<'a> {
messages: Vec<Message<'a>>,
}
fn strip_system_messages(messages: &[OpenAIMessage]) -> Vec<OpenAIMessage> {
messages
.iter()
.filter(|message| message.role != "system")
.cloned()
.collect()
}
fn strip_leading_tool_messages(messages: Vec<OpenAIMessage>) -> Vec<OpenAIMessage> {
match messages.iter().position(|message| message.role != "tool") {
Some(first_non_tool_index) => messages.into_iter().skip(first_non_tool_index).collect(),
None => Vec::new(),
}
}
fn prepare_auto_memory_messages_for_request(
loaded_messages: &[OpenAIMessage],
context_length: usize,
) -> Vec<OpenAIMessage> {
let start_idx = loaded_messages.len().saturating_sub(context_length);
strip_leading_tool_messages(loaded_messages[start_idx..].to_vec())
}
fn prepare_auto_memory_messages_for_persistence(
all_messages: &[OpenAIMessage],
context_length: usize,
) -> Vec<OpenAIMessage> {
let non_system_messages = strip_system_messages(all_messages);
let start_idx = non_system_messages.len().saturating_sub(context_length);
non_system_messages[start_idx..].to_vec()
}
/// Provider request settings must follow any endpoint fallback learned during the run.
struct CompactionContext<'a> {
compactor: Option<&'a mut Compactor>,
timeout: Option<std::time::Duration>,
credentials: &'a windmill_ai::credentials::ProviderCredentials,
args: &'a AIAgentArgs,
client: &'a AuthedClient,
job: &'a MiniPulledJob,
conn: &'a Connection,
}
/// Bills and logs successful checkpoints. Request and persistence policy stay with their callers.
async fn compact_if_needed(
ctx: CompactionContext<'_>,
query_builder: &dyn windmill_ai::query_builder::QueryBuilder,
include_usage: bool,
messages: &mut AgentHistory,
trigger: CompactionTrigger,
final_usage: &mut Option<TokenUsage>,
) -> bool {
let (Some(compactor), Some(timeout)) = (ctx.compactor, ctx.timeout) else {
return false;
};
let pass = compactor
.compact(
messages,
trigger,
&CompactionRequest {
query_builder,
credentials: ctx.credentials,
model: ctx.args.provider.get_model(),
timeout,
client: ctx.client,
workspace_id: &ctx.job.workspace_id,
job_id: &ctx.job.id,
include_usage,
},
)
.await;
if let Some(usage) = pass.usage {
match final_usage {
Some(existing) => existing.accumulate(&usage),
None => *final_usage = Some(usage),
}
}
if pass.changed {
append_logs(
&ctx.job.id,
&ctx.job.workspace_id,
"AI agent memory summarized; the run's action results are preserved.\n".to_string(),
ctx.conn,
)
.await;
}
pass.changed
}
/// The inputs a linked step supplies for itself; the resource holds the rest of the brain.
const FLOW_LOCAL_AGENT_KEYS: [&str; 5] = [
"user_message",
"user_attachments",
"enabled_tools",
"memory_id",
"previous_messages",
];
/// The flow-local inputs that name a conversation, which a saved agent never carries.
const STEP_HISTORY_KEYS: [&str; 2] = ["memory_id", "previous_messages"];
/// Where one agent invocation's history comes from.
#[derive(Debug)]
enum HistorySource<'a> {
/// Supplied by the flow and replayed as is: memory is neither read nor written.
Messages(&'a [OpenAIMessage]),
Managed {
memory_id: Uuid,
bound: MemoryBound,
},
Stateless,
}
/// What keeps a managed conversation inside the model's context.
#[derive(Debug, Clone, Copy)]
enum MemoryBound {
/// Only the most recent messages are sent and kept.
LastMessages(usize),
/// Everything is sent and kept; compaction summarizes what no longer fits.
Compaction,
}
impl MemoryBound {
/// How many of the memory's messages a run reads and writes back. Compaction keeps all of
/// them: the summary it writes is what bounds the conversation, so truncating on top of it
/// would drop the tail that summary was written to precede.
fn messages_to_keep(self) -> usize {
match self {
MemoryBound::LastMessages(context_length) => context_length,
MemoryBound::Compaction => usize::MAX,
}
}
}
/// A step's memory id counts only as the step authored it. A static empty value is a form
/// placeholder, so it reads as unset rather than as an expression that evaluated to nothing, which
/// runs without memory; an AI-filled value would let the model choose which memory the agent reads.
fn keep_authored_memory_id(
args: &mut AIAgentArgs,
step_input_transforms: &HashMap<String, InputTransform>,
) {
match step_input_transforms.get("memory_id") {
Some(InputTransform::Javascript { .. }) => {}
Some(InputTransform::Static { .. }) if args.memory_id.as_deref() != Some("") => {}
_ => args.memory_id = None,
}
}
/// Reconciles the step's history inputs, the agent's memory policy and the run's memory id. A step
/// holds one of two shapes: an older `auto` or `manual` memory, read as the editor that wrote it
/// meant it, or the current setting plus the step's own history inputs. Also returns lines for the
/// job log: an input that went unused, or a policy that remembers ending up stateless.
fn resolve_history_source<'a>(
args: &'a AIAgentArgs,
run_memory_id: Option<Uuid>,
workspace_id: &str,
flow_path: &str,
) -> (HistorySource<'a>, Vec<&'static str>) {
let mut notes = Vec::new();
let managed = |bound: MemoryBound, notes: &mut Vec<&'static str>| {
managed_memory_id(args, run_memory_id, workspace_id, flow_path, notes)
.map_or(HistorySource::Stateless, |memory_id| {
HistorySource::Managed { memory_id, bound }
})
};
match &args.memory {
// The step's own history inputs came after these, so a step that still holds one reads it
// alone: what it did before the editor offered them is what it keeps doing.
Some(Memory::Manual { messages }) => {
note_unread_step_inputs(&mut notes, args);
(HistorySource::Messages(messages), notes)
}
Some(Memory::Auto { context_length, memory_id }) => {
note_unread_step_inputs(&mut notes, args);
// An id baked in at save time only ever applied when the run carried none.
match run_memory_id.or(*memory_id) {
Some(memory_id) => (
HistorySource::Managed {
memory_id,
bound: MemoryBound::LastMessages(*context_length),
},
notes,
),
None => {
notes.push(NO_RUN_MEMORY_ID);
(HistorySource::Stateless, notes)
}
}
}
Some(Memory::Window { context_length }) => {
let source = managed(MemoryBound::LastMessages(*context_length), &mut notes);
(source, notes)
}
Some(Memory::Compaction { .. }) => {
let source = managed(MemoryBound::Compaction, &mut notes);
(source, notes)
}
Some(Memory::Off) | None => {
if args.memory_id.as_deref().is_some_and(|id| !id.is_empty()) {
notes.push("Managed memory is off, so this step's memory id is ignored.");
}
match &args.previous_messages {
Some(messages) => (HistorySource::Messages(messages), notes),
None => (HistorySource::Stateless, notes),
}
}
}
}
const NO_RUN_MEMORY_ID: &str =
"No memory id was passed to this run, so the agent runs without memory.";
/// The memory a step under a current setting reads and writes: its own id where it set one,
/// otherwise the run's. `None` is a step that ends up stateless, with the reason noted.
fn managed_memory_id(
args: &AIAgentArgs,
run_memory_id: Option<Uuid>,
workspace_id: &str,
flow_path: &str,
notes: &mut Vec<&'static str>,
) -> Option<Uuid> {
if args
.previous_messages
.as_ref()
.is_some_and(|messages| !messages.is_empty())
{
notes.push("Managed memory is on, so this step's previous messages are ignored.");
}
match args.memory_id.as_deref() {
Some("") => {
notes.push(
"This step's memory id evaluated to an empty value, so the agent runs without memory.",
);
None
}
Some(step_memory_id) => Some(memory_key(workspace_id, flow_path, step_memory_id)),
None => run_memory_id.or_else(|| {
notes.push(NO_RUN_MEMORY_ID);
None
}),
}
}
/// An older memory setting reads neither history input, which is only visible in the job log: the
/// editor offers them on a step that has been moved to the current settings.
fn note_unread_step_inputs(notes: &mut Vec<&'static str>, args: &AIAgentArgs) {
if args.memory_id.as_deref().is_some_and(|id| !id.is_empty()) {
notes.push("This step uses an older memory setting, so its memory id is not read.");
}
if args
.previous_messages
.as_ref()
.is_some_and(|messages| !messages.is_empty())
{
notes
.push("This step uses an older memory setting, so its previous messages are not read.");
}
}
/// Whether a request has something to ask the model. Only text output sends previous messages, so
/// an image prompt comes from the user message alone. An empty list is no conversation, except
/// under a legacy `manual` memory, which ran on whatever list it held.
fn has_prompt(
history: &HistorySource,
has_user_message: bool,
is_text_output: bool,
legacy_list: bool,
) -> bool {
has_user_message
|| (is_text_output
&& (legacy_list || matches!(history, HistorySource::Messages(m) if !m.is_empty())))
}
fn find_module_by_id(
modules: &Vec<FlowModule>,
target_id: &str,
) -> Result<Option<FlowModule>, Error> {
let mut found: Option<FlowModule> = None;
FlowModule::traverse_modules(modules, &mut |module| {
if found.is_none() && module.id == target_id {
found = Some(module.clone());
}
Ok(())
})
.map_err(|e| Error::internal_err(format!("Failed to traverse flow modules: {e}")))?;
Ok(found)
}
async fn find_ai_agent_tool_module_in_parent_agent(
modules: &Vec<FlowModule>,
parent_agent_step_id: &str,
tool_module_id: &str,
client: &AuthedClient,
) -> Result<Option<FlowModule>, Error> {
let Some(parent_agent_module) = find_module_by_id(modules, parent_agent_step_id)? else {
return Ok(None);
};
let FlowModuleValue::AIAgent { tools, agent, tool_inputs, .. } =
parent_agent_module.get_value()?
else {
return Ok(None);
};
// A linked parent carries no tools on the module (they live in the resource, resolved only in
// the main execution branch). Resolve them from the resource here too, so a nested agent tool
// of a saved+linked agent can still be located when it runs as its own job.
let mut tools = if let Some(agent_ref) = agent.as_deref() {
let agent_path = agent_ref
.trim_start_matches("$res:")
.trim_start_matches("res://");
// Definitions only: resolving their defaults here would hit the same inaccessible resources.
let resource_value = client
.get_resource_value::<serde_json::Value>(agent_path)
.await
.map_err(|e| {
Error::internal_err(format!(
"failed to load ai_agent resource {agent_path}: {e}"
))
})?;
match resource_value {
serde_json::Value::Object(mut map) => match map.remove("tools") {
Some(t) => serde_json::from_value::<Vec<AgentTool>>(t).map_err(|e| {
Error::internal_err(format!(
"invalid tools in ai_agent resource {agent_path}: {e}"
))
})?,
None => Vec::new(),
},
_ => Vec::new(),
}
} else {
tools
};
// The nested job reads its history inputs from the tool's transforms, which must carry the
// host flow's bindings as the parent evaluated them.
overlay_tool_inputs(&mut tools, &tool_inputs);
for tool in tools {
if tool.id == tool_module_id {
return Ok(Option::<FlowModule>::from(&tool));
}
}
Ok(None)
}
/// Resolve the `description` sent to the model for an AI agent tool, in priority order:
/// an explicit per-tool description, then one auto-derived from the underlying runnable,
/// then the tool name as the historical last-resort fallback. Blank/whitespace-only values
/// at each level are skipped so a lower-priority source can still apply.
fn resolve_tool_description(
user_description: Option<String>,
derived_description: Option<String>,
tool_name: &str,
) -> String {
fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
non_empty(user_description)
.or_else(|| non_empty(derived_description))
.unwrap_or_else(|| tool_name.to_string())
}
/// Fetch a workspace script's stored description by hash, used to auto-derive an AI agent
/// tool's description when the user did not provide an explicit one. Returns `None` when the
/// script has no description or on any lookup error, so the caller falls back to the tool name.
async fn fetch_script_description(db: &DB, w_id: &str, hash: i64) -> Option<String> {
sqlx::query_scalar!(
"SELECT description FROM script WHERE hash = $1 AND workspace_id = $2",
hash,
w_id,
)
.fetch_optional(db)
.await
.ok()
.flatten()
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty())
}
/// Overlay a linked step's host-local tool wiring onto the agent resource's tools. For each tool
/// id present in `tool_inputs`, merge its per-input transforms into that tool's `input_transforms`
/// (step wins). Only `FlowModule` tools carry input transforms; MCP/websearch tools are skipped.
fn overlay_tool_inputs(
tools: &mut [AgentTool],
tool_inputs: &HashMap<String, HashMap<String, InputTransform>>,
) {
if tool_inputs.is_empty() {
return;
}
for tool in tools.iter_mut() {
let Some(overrides) = tool_inputs.get(&tool.id) else {
continue;
};
let ToolValue::FlowModule(fmv) = &mut tool.value else {
continue;
};
let input_transforms = match fmv {
FlowModuleValue::Script { input_transforms, .. }
| FlowModuleValue::RawScript { input_transforms, .. }
| FlowModuleValue::FlowScript { input_transforms, .. }
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
_ => continue,
};
for (key, transform) in overrides {
input_transforms.insert(key.clone(), transform.clone());
}
}
}
/// What every websearch entry is named by, whatever label it carries. Web search reaches the model
/// as a provider capability rather than a tool, so it has no model-facing name of its own, and the
/// editor's label is not one: a flow module tool could carry the same one, and enabling that tool
/// would then silently turn web search on with it.
///
/// Reserved, on the `__wm_` prefix this codebase uses for names it keeps for itself, and held that
/// way by `flow_module_tool_name` refusing to advertise a tool that takes it.
const WEBSEARCH_ENABLED_NAME: &str = "__wm_web_search";
/// The name a flow module tool is advertised to the model under.
///
/// Rejected rather than skipped: a tool the model is never shown is a tool the agent silently does
/// not have, and a run that quietly drops one is harder to explain than a run that will not start.
fn flow_module_tool_name(summary: Option<&str>) -> Result<&str, Error> {
match summary {
Some(name) if name == WEBSEARCH_ENABLED_NAME => Err(Error::internal_err(format!(
"Invalid tool name: {name:?} is reserved for enabling web search"
))),
Some(name) if TOOL_NAME_REGEX.is_match(name) => Ok(name),
other => Err(Error::internal_err(format!("Invalid tool name: {other:?}"))),
}
}
/// The name a run enables a roster entry by: the name the model is shown, except for an entry the
/// model is shown nothing of, which cannot be named by a label others may share. An MCP server is
/// named by the resource it points at, web search by `WEBSEARCH_ENABLED_NAME`.
///
/// The MCP path is bare. The roster stores it as authored, `$res:` and all, but a name is an
/// argument value and one carrying that prefix is resolved to the resource itself before the worker
/// is handed its args, so the prefixed form is not something the list can hold.
fn tool_enabled_name(tool: &AgentTool) -> Option<&str> {
match &tool.value {
ToolValue::Mcp(mcp) => Some(mcp.resource_path.trim_start_matches("$res:")),
ToolValue::Websearch(_) => Some(WEBSEARCH_ENABLED_NAME),
_ => tool.summary.as_deref(),
}
}
/// The roster a run advertises, given the names it enabled.
///
/// `None` advertises the whole roster, which is what every agent written before the field existed
/// relies on; an empty list advertises nothing.
///
/// Whole entries, decided before any of them is resolved: an MCP server a run switched off is never
/// contacted, and reading its resource, refreshing its token and opening a client are each things
/// that can fail a run. Which tools a server exposes stays its entry's own `include_tools` /
/// `exclude_tools`, the only place that choice is made.
fn narrow_roster(tools: Vec<AgentTool>, enabled_tools: Option<&[String]>) -> Vec<AgentTool> {
let Some(enabled) = enabled_tools else {
return tools;
};
tools
.into_iter()
.filter(|t| tool_enabled_name(t).is_some_and(|name| enabled.iter().any(|n| n == name)))
.collect()
}
/// The log line for names in `enabled_tools` that name nothing on the agent, if any. The list can
/// be computed per run, so a name that has since been renamed away must not fail the step — but it
/// would otherwise silently narrow the agent, so the run says how many of its names matched nothing.
///
/// Counted, never quoted: a name is an argument value, and one written as `$var:path` reaches the
/// worker already replaced by the variable's own value. Quoting it would write that value to the
/// job log, where the masks a job registers never reach it — they are applied to a subprocess's
/// output in `handle_child` and nowhere else, and `append_logs` stores what it is given verbatim.
fn unmatched_enabled_tools_message(
enabled_tools: &[String],
advertised: &[&str],
) -> Option<String> {
let unmatched = enabled_tools
.iter()
.filter(|name| !advertised.contains(&name.as_str()))
.count();
if unmatched == 0 {
return None;
}
let subject = if unmatched == 1 { "name" } else { "names" };
Some(format!(
"--- ENABLED TOOLS: {unmatched} {subject} named no tool of this agent and had no effect ---\n"
))
}
pub async fn handle_ai_agent_job(
// connection
conn: &Connection,
db: &DB,
// agent job
job: &MiniPulledJob,
// job execution context
client: &AuthedClient,
canceled_by: &mut Option<CanceledBy>,
mem_peak: &mut i32,
occupancy_metrics: &mut OccupancyMetrics,
worker_dir: &str,
base_internal_url: &str,
worker_name: &str,
hostname: &str,
killpill_rx: &mut tokio::sync::broadcast::Receiver<()>,
has_stream: &mut bool,
) -> Result<Box<RawValue>, Error> {
// build_args_map returns None if no $res:/$var: transforms needed, in which case use original args
let local_args = match build_args_map(job, client, conn).await? {
Some(transformed) => transformed,
None => job.args.as_ref().map(|a| a.0.clone()).unwrap_or_default(),
};
// Handle dry_run mode - check credentials without making API calls.
// The credentials check is always invoked inline (provider present, no agent link and no
// parent flow), so it resolves before any flow/agent-resource context is fetched.
let is_credentials_check = local_args
.get("credentials_check")
.map(|v| v.get().trim() == "true")
.unwrap_or(false);
if is_credentials_check {
let args = serde_json::from_str::<AIAgentArgs>(&serde_json::to_string(&local_args)?)?;
return handle_credentials_check(&args.provider).await;
}
// flow_step_id is set by the flow executor for top-level AI agents.
// For nested AI agent tools, it's not set (to avoid triggering flow step
// machinery on a parent that has no v2_job_status row), so we extract the
// tool module ID from the runnable_path which has the form ".../tools/{id}".
let flow_step_id = job
.flow_step_id
.as_deref()
.or_else(|| job.runnable_path().rsplit_once("/tools/").map(|(_, id)| id))
.ok_or_else(|| Error::internal_err("AI agent job has no flow step id".to_string()))?
.to_string();
let flow_step_id = &flow_step_id;
let Some(immediate_parent_job) = &job.parent_job else {
return Err(Error::internal_err(
"AI agent job has no parent job".to_string(),
));
};
let mut flow_job_id = *immediate_parent_job;
let mut flow_job = get_flow_job_runnable_and_raw_flow(db, &flow_job_id).await?;
let direct_parent_job_kind = flow_job.kind;
let direct_parent_job_flow_step_id = flow_job.flow_step_id.clone();
// If the direct parent is an AI agent (nested tool case), go one level up to the flow.
if flow_job.kind == JobKind::AIAgent {
let Some(parent_job_id) = flow_job.parent_job else {
return Err(Error::internal_err(
"AI agent parent has no parent job".to_string(),
));
};
flow_job_id = parent_job_id;
flow_job = get_flow_job_runnable_and_raw_flow(db, &flow_job_id).await?;
if !matches!(
flow_job.kind,
JobKind::Flow | JobKind::FlowNode | JobKind::FlowPreview
) {
return Err(Error::internal_err(
"AI agent nesting beyond 2 levels is not supported. \
Only flow → agent → nested agent tool is allowed."
.to_string(),
));
}
}
let flow_data = match flow_job.kind {
JobKind::Flow | JobKind::FlowNode => {
cache::job::fetch_flow(db, &flow_job.kind, flow_job.runnable_id).await?
}
JobKind::FlowPreview => {
cache::job::fetch_preview_flow(db, &flow_job_id, flow_job.raw_flow).await?
}
_ => {
return Err(Error::internal_err(
"expected parent flow, flow preview or flow node for ai agent job".to_string(),
));
}
};
let value = flow_data.value();
let module = if direct_parent_job_kind == JobKind::AIAgent {
let parent_agent_step_id = direct_parent_job_flow_step_id.as_deref().ok_or_else(|| {
Error::internal_err("Parent AI agent job has no flow_step_id".to_string())
})?;
find_ai_agent_tool_module_in_parent_agent(
&value.modules,
parent_agent_step_id,
flow_step_id,
client,
)
.await?
} else {
find_module_by_id(&value.modules, flow_step_id)?
};
let Some(module) = module else {
return Err(Error::internal_err(
"AI agent module not found in flow".to_string(),
));
};
let summary = module.summary.clone();
let FlowModuleValue::AIAgent {
tools: module_tools,
omit_output_from_conversation,
agent,
tool_inputs,
input_transforms: step_input_transforms,
..
} = module.get_value()?
else {
return Err(Error::internal_err(
"AI agent module is not an AI agent".to_string(),
));
};
// A linked step takes its brain and tools from the resource and keeps only its own flow-local
// inputs. The brain and the roster stay rigid; what the step binds to this flow is the message
// it asks, which of those tools this use may call, the conversation it is part of (its memory
// id and previous messages), and the tools' own inputs — the last overlaid from `tool_inputs`
// below.
let (mut args, tools): (AIAgentArgs, Vec<AgentTool>) = if let Some(agent_ref) = agent.as_deref()
{
let agent_path = agent_ref
.trim_start_matches("$res:")
.trim_start_matches("res://");
// Read raw and interpolate only the brain below. Interpolating the whole resource would also
// resolve each tool's default `$res:`/`$var:`, which a host flow may be overriding and which
// may be unreadable to whoever runs this flow — an unused tool could then fail the agent.
let resource_value = client
.get_resource_value::<serde_json::Value>(agent_path)
.await
.map_err(|e| {
Error::internal_err(format!(
"failed to load ai_agent resource {agent_path}: {e}"
))
})?;
let mut config = match resource_value {
serde_json::Value::Object(map) => map,
_ => {
return Err(Error::internal_err(format!(
"ai_agent resource {agent_path} must be a JSON object"
)))
}
};
let mut tools = match config.remove("tools") {
Some(t) => serde_json::from_value::<Vec<AgentTool>>(t).map_err(|e| {
Error::internal_err(format!(
"invalid tools in ai_agent resource {agent_path}: {e}"
))
})?,
None => Vec::new(),
};
overlay_tool_inputs(&mut tools, &tool_inputs);
// The resource is not validated against a schema, so a history input it happens to carry
// is dropped before interpolation, where a bad `$res:` in it would fail the step. The
// other flow-local keys stay: a resource's own user message is the step's fallback.
for key in STEP_HISTORY_KEYS {
config.remove(key);
}
let brain = transform_json_value(
"ai_agent",
client,
&job.workspace_id,
serde_json::Value::Object(config),
job,
conn,
0,
)
.await?;
let mut brain = match brain {
serde_json::Value::Object(map) => map,
_ => {
return Err(Error::internal_err(format!(
"ai_agent resource {agent_path} must be a JSON object"
)))
}
};
// Only after interpolating the resource: these are caller-controlled and already resolved by
// build_args_map, so passing them through it again would expand contextual values —
// `$WM_TOKEN` in a user message would reach the model provider.
for key in FLOW_LOCAL_AGENT_KEYS {
if let Some(v) = local_args.get(key) {
brain.insert(
key.to_string(),
serde_json::from_str(v.get()).unwrap_or(serde_json::Value::Null),
);
}
}
let args = serde_json::from_value::<AIAgentArgs>(serde_json::Value::Object(brain))
.map_err(|e| {
Error::internal_err(format!(
"invalid ai_agent resource config {agent_path}: {e}"
))
})?;
(args, tools)
} else {
let args = serde_json::from_str::<AIAgentArgs>(&serde_json::to_string(&local_args)?)?;
// "Edit" on a linked step clears `agent` but keeps the host's `tool_inputs`, so overlay them
// here too: a flow persisted mid-edit must still bind its tools to this flow's context
// rather than the agent author's.
let mut tools = module_tools;
overlay_tool_inputs(&mut tools, &tool_inputs);
(args, tools)
};
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 {
tools
.into_iter()
.filter(|t| {
!matches!(
&t.value,
ToolValue::FlowModule(FlowModuleValue::AIAgent { .. })
)
})
.collect()
} else {
tools
};
// Narrow the roster to the tools this run enabled, before the loop below pays a script or hub
// fetch per tool.
let enabled_tools = args.enabled_tools.as_deref();
// Taken before the narrowing consumes the roster, and only by a run that narrows: they are what
// its names are matched against, so they are also what tells it a name matched nothing.
let roster_names: Vec<String> = match enabled_tools {
Some(_) => tools
.iter()
.filter_map(|t| tool_enabled_name(t).map(str::to_string))
.collect(),
None => Vec::new(),
};
let tools = narrow_roster(tools, enabled_tools);
// Separate Windmill tools from MCP tools, websearch, and extract MCP resource configs
let mut windmill_modules: Vec<FlowModule> = Vec::new();
// Explicit per-tool descriptions keyed by tool id. When set, these override the
// description auto-derived from the underlying runnable when building tool definitions.
let mut tool_descriptions: HashMap<String, String> = HashMap::new();
#[allow(unused_mut)]
let mut mcp_configs: Vec<crate::ai::utils::McpResourceConfig> = Vec::new();
let mut has_websearch = false;
for tool in tools {
match &tool.value {
#[allow(unused_variables)]
ToolValue::Mcp(mcp_config) => {
#[cfg(feature = "mcp")]
{
// This is an MCP tool - extract config
tracing::debug!(
"MCP server module: path={}, include={:?}, exclude={:?}",
mcp_config.resource_path,
mcp_config.include_tools,
mcp_config.exclude_tools
);
mcp_configs.push(crate::ai::utils::McpResourceConfig {
resource_path: mcp_config.resource_path.clone(),
include_tools: Some(mcp_config.include_tools.clone()),
exclude_tools: Some(mcp_config.exclude_tools.clone()),
});
}
#[cfg(not(feature = "mcp"))]
{
tracing::warn!("MCP tool detected but MCP feature is not enabled");
}
}
ToolValue::FlowModule(_) => {
// Regular Windmill flow module (script, flow, etc.) - convert to FlowModule
tracing::debug!("Windmill module: {:?}", tool.id);
if let Some(description) = tool
.description
.as_ref()
.map(|d| d.trim())
.filter(|d| !d.is_empty())
{
tool_descriptions.insert(tool.id.clone(), description.to_string());
}
if let Some(flow_module) = Option::<FlowModule>::from(&tool) {
windmill_modules.push(flow_module);
}
}
ToolValue::Websearch(_) => {
// WebSearch tool - mark as enabled
tracing::debug!("WebSearch tool enabled");
has_websearch = true;
}
}
}
// Process Windmill flow modules into Tool definitions
let tools = futures::future::try_join_all(windmill_modules.into_iter().map(|mut t| {
let conn = conn;
let db = db;
let job = job;
let user_description = tool_descriptions.get(&t.id).cloned();
async move {
let summary = flow_module_tool_name(t.summary.as_deref())?;
// Extract schema, input_transforms, and an auto-derived description from the module value
let module_value = t.get_value()?;
let (schema, input_transforms, derived_description) = match &module_value {
FlowModuleValue::Script {
hash,
path,
tag_override,
input_transforms,
is_trigger,
pass_flow_input_directly,
} => {
let derived_description: Option<String>;
let schema = match hash {
Some(hash) => {
let (_, metadata) = cache::script::fetch(conn, hash.clone()).await?;
derived_description =
fetch_script_description(db, &job.workspace_id, hash.0).await;
Ok::<_, Error>(
metadata
.schema
.clone()
.map(|s| RawValue::from_string(s).ok())
.flatten(),
)
}
None => {
if path.starts_with("hub/") {
let hub_script = get_full_hub_script_by_path(
StripPath(path.to_string()),
&HTTP_CLIENT,
None,
)
.await?;
// Hub scripts carry their free-text description in `summary`.
derived_description = hub_script
.summary
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(Some(hub_script.schema))
} else {
let hash = get_latest_hash_for_path(
db,
db,
&job.workspace_id,
path.as_str(),
true,
)
.await?
.0;
// update module definition to use a fixed hash so all tool calls match the same schema
t.value = to_raw_value(&FlowModuleValue::Script {
hash: Some(hash),
path: path.clone(),
tag_override: tag_override.clone(),
input_transforms: input_transforms.clone(),
is_trigger: *is_trigger,
pass_flow_input_directly: *pass_flow_input_directly,
});
derived_description =
fetch_script_description(db, &job.workspace_id, hash.0).await;
let (_, metadata) = cache::script::fetch(conn, hash).await?;
Ok(metadata
.schema
.clone()
.map(|s| RawValue::from_string(s).ok())
.flatten())
}
}
}?;
(schema, input_transforms, derived_description)
}
FlowModuleValue::RawScript { content, language, input_transforms, .. } => {
let schema = Some(parse_raw_script_schema(&content, &language).await?);
(schema, input_transforms, None)
}
FlowModuleValue::AIAgent { input_transforms, .. } => {
// By convention for AIAgent tools, only user_message is expected to be AI-filled.
(
Some(
RawValue::from_string(AI_AGENT_TOOL_SCHEMA.get().to_string())
.expect("AI_AGENT_TOOL_SCHEMA should always be valid JSON"),
),
input_transforms,
None,
)
}
_ => {
return Err(Error::internal_err(format!(
"Unsupported tool: {}",
summary
)));
}
};
// Filter schema based on user given input transforms
let schema = if let Some(s) = schema {
Some(filter_schema_by_input_transforms(s, input_transforms)?)
} else {
None
};
let description =
resolve_tool_description(user_description, derived_description, summary);
Ok(Tool {
def: ToolDef {
r#type: "function".to_string(),
function: ToolDefFunction {
name: summary.to_string(),
description: Some(description),
parameters: schema.unwrap_or_else(|| {
to_raw_value(&serde_json::json!({
"type": "object",
"properties": {},
"required": [],
}))
}),
},
},
module: Some(t),
mcp_source: None,
})
}
}))
.await?;
// Load MCP tools if configured
let mut tools = tools;
let mcp_clients = if !mcp_configs.is_empty() {
let (clients, mcp_tools) =
load_mcp_tools(db, &job.workspace_id, mcp_configs, client).await?;
tools.extend(mcp_tools);
clients
} else {
HashMap::new()
};
if let Some(enabled) = enabled_tools {
let matchable: Vec<&str> = roster_names.iter().map(|s| s.as_str()).collect();
windmill_common::feature_usage::log_feature_usage(
"ai_agent",
"dynamic_tools",
if tools.is_empty() && !has_websearch {
"no_tools"
} else {
"tools"
},
);
if let Some(message) = unmatched_enabled_tools_message(enabled, &matchable) {
append_logs(&job.id, &job.workspace_id, message, conn).await;
}
}
let mut inner_occupancy_metrics = occupancy_metrics.clone();
let stream_notifier = StreamNotifier::new(conn, job);
if let Some(stream_notifier) = stream_notifier {
stream_notifier.update_flow_status_with_stream_job();
}
let flow_status_job = if direct_parent_job_kind == JobKind::AIAgent {
None
} else {
Some(flow_job_id)
};
// Create cancellation signal for graceful shutdown
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
let tool_abort_handles: ToolAbortHandles = Arc::new(std::sync::Mutex::new(Vec::new()));
/// Grace period for in-flight tool calls to complete after cancellation.
const CANCEL_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(30);
let outcome = {
let agent_fut = run_agent(
db,
conn,
job,
flow_status_job.as_ref(),
Some(flow_step_id.as_str()),
&args,
&tools,
&mcp_clients,
summary.as_deref(),
client,
&mut inner_occupancy_metrics,
worker_dir,
base_internal_url,
worker_name,
hostname,
killpill_rx,
has_stream,
has_websearch,
omit_output_from_conversation,
cancel_rx,
tool_abort_handles.clone(),
);
let mut occupancy_opt = Some(occupancy_metrics);
run_future_with_polling_update_job_poller_graceful(
job.id,
job.timeout,
conn,
mem_peak,
canceled_by,
agent_fut,
worker_name,
&job.workspace_id,
&mut occupancy_opt,
Box::pin(futures::stream::once(async { 0 })),
cancel_tx,
CANCEL_GRACE_PERIOD,
)
.await?
};
// agent_fut and update_job are now dropped — borrows on mcp_clients and canceled_by released
// Cleanup MCP clients
cleanup_mcp_clients(mcp_clients).await;
let format_cancel_info = |cb: &Option<CanceledBy>| {
cb.as_ref()
.map_or(("unknown".to_string(), "unknown".to_string()), |x| {
(
x.username.clone().unwrap_or_default(),
x.reason.clone().unwrap_or_default(),
)
})
};
match outcome {
GracefulPollOutcome::Ok(result) => Ok(result),
GracefulPollOutcome::Timeout(ms) => {
tracing::error!("AI agent timeout after {}s", ms / 1000);
// A tool dispatched to another worker group would otherwise still run later, after
// the agent that wanted its result is gone.
let reason = format!("parent AI agent {} timed out", job.id);
let cb = CanceledBy { username: None, reason: Some(reason) };
cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, Some(cb)).await;
Err(Error::ExecutionErr(format!(
"AI agent timeout after (>{}s)",
ms / 1000
)))
}
GracefulPollOutcome::Cancelled { canceled_by: cb } => {
let (by, reason) = format_cancel_info(&cb);
Err(Error::ExecutionErr(format!(
"Job cancelled by {by} (reason: {reason})"
)))
}
GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => {
let (by, reason) = format_cancel_info(&cb);
// Abort any still-running spawned tool tasks
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
for handle in tool_abort_handles.lock().unwrap().drain(..) {
handle.abort();
}
// Hard timeout: clean up orphaned jobs still stuck in v2_job_queue
cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, cb).await;
Err(Error::ExecutionErr(format!(
"Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)"
)))
}
GracefulPollOutcome::AlreadyCompleted => {
Err(Error::AlreadyCompleted("Job already completed".to_string()))
}
}
}
/// OpenAI rejects a `prompt_cache_key` over 64 characters
/// (`Invalid 'prompt_cache_key': string too long`), and a runnable path alone can pass
/// that. Fold an over-long key into a digest of itself: same step still yields the same
/// key across runs, which is the whole property that routes them to one cache.
fn bounded_prompt_cache_key(raw: &str) -> String {
const MAX_LEN: usize = 64;
if raw.len() <= MAX_LEN {
return raw.to_string();
}
let suffix = hex::encode(&sha2::Sha256::digest(raw.as_bytes())[..16]);
// Keep a readable head so a key stays traceable to its workspace in provider logs.
let mut head = MAX_LEN - suffix.len() - 1;
while head > 0 && !raw.is_char_boundary(head) {
head -= 1;
}
format!("{}:{}", &raw[..head], suffix)
}
#[async_recursion]
pub async fn run_agent(
// connection
db: &DB,
conn: &Connection,
// agent job and flow data
job: &MiniPulledJob,
parent_job: Option<&Uuid>,
flow_step_id_override: Option<&str>,
args: &AIAgentArgs,
tools: &[Tool],
mcp_clients: &HashMap<String, Arc<McpClient>>,
summary: Option<&str>,
// job execution context
client: &AuthedClient,
occupancy_metrics: &mut OccupancyMetrics,
worker_dir: &str,
base_internal_url: &str,
worker_name: &str,
hostname: &str,
killpill_rx: &mut tokio::sync::broadcast::Receiver<()>,
has_stream: &mut bool,
has_websearch: bool,
omit_output_from_conversation: bool,
// cancellation signal from parent
cancel_rx: tokio::sync::watch::Receiver<bool>,
// 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 credentials = args.provider.to_provider_credentials(db).await?;
let base_url = &credentials.base_url;
let api_key = credentials.api_key.as_deref().unwrap_or("");
// Create the query builder for the provider
let mut query_builder = create_query_builder(&credentials, args.provider.get_model());
if query_builder.supports_chat_completions_fallback(base_url)
&& is_chat_completions_only(base_url, args.provider.get_model())
{
query_builder = create_chat_completions_query_builder(&credentials);
}
// These outlive the iteration that discovers them: a request shape or a route the
// endpoint rejected once stays rejected for the whole step.
let mut include_usage = true;
let mut include_prompt_cache_key = true;
// Initialize messages
let mut messages =
if let Some(system_prompt) = args.system_prompt.clone().filter(|s| !s.is_empty()) {
vec![OpenAIMessage {
role: "system".to_string(),
content: Some(OpenAIContent::Text(system_prompt)),
..Default::default()
}]
} else {
vec![]
};
// Effective flow_step_id: override for nested agents, otherwise from job
let effective_flow_step_id: Option<&str> =
flow_step_id_override.or(job.flow_step_id.as_deref());
// Keyed on the step, not the run: every run of this step opens with the same system
// prompt and tool definitions, and each agent-loop iteration extends the previous
// one's prefix. Above ~15 requests/minute one key starts missing again, which is a
// reason to split it further, never to make it per-run.
let prompt_cache_key = bounded_prompt_cache_key(&format!(
"{}:{}:{}",
job.workspace_id,
job.runnable_path(),
effective_flow_step_id.unwrap_or_default()
));
// Fetch flow context for input transforms context, chat and memory
let mut flow_context = get_flow_context(db, job).await;
// The run's memory id is also the chat conversation id, which a step's own memory id never
// replaces.
let conversation_id = flow_context
.flow_status
.as_ref()
.and_then(|fs| fs.memory_id);
let (history, history_notes) = resolve_history_source(
args,
conversation_id,
&job.workspace_id,
flow_context.flow_path.as_deref().unwrap_or_default(),
);
// Check if user_message is provided and non-empty
let has_user_message = args
.user_message
.as_ref()
.map(|m| !m.is_empty())
.unwrap_or(false);
let is_text_output = output_type == &OutputType::Text;
if is_text_output {
for note in &history_notes {
append_logs(&job.id, &job.workspace_id, format!("{note}\n"), conn).await;
}
} else if !matches!(args.memory, None | Some(Memory::Off))
|| args.memory_id.is_some()
|| args.previous_messages.is_some()
{
append_logs(
&job.id,
&job.workspace_id,
"Image output sends no history, so memory and previous messages are not read.\n",
conn,
)
.await;
}
// A `manual` memory sent whatever list it held, an empty one included, so a step that still has
// one keeps running without a user message.
let legacy_list = matches!(args.memory, Some(Memory::Manual { .. }));
if !has_prompt(&history, has_user_message, is_text_output, legacy_list) {
let missing = if !is_text_output {
"'user_message' must be provided for image output"
} else if matches!(
args.memory,
Some(Memory::Window { .. } | Memory::Compaction { .. } | Memory::Auto { .. })
) {
"'user_message' must be provided while managed memory is on"
} else {
"Either 'previous_messages' or 'user_message' must be provided"
};
return Err(Error::internal_err(missing.to_string()));
}
if matches!(output_type, OutputType::Text) {
match &history {
HistorySource::Messages(provided) => messages.extend(provided.iter().cloned()),
HistorySource::Managed { memory_id, bound } => {
if let Some(step_id) = effective_flow_step_id {
match read_from_memory(db, &job.workspace_id, *memory_id, step_id).await {
Ok(Some(loaded_messages)) => {
let messages_to_load = prepare_auto_memory_messages_for_request(
&loaded_messages,
bound.messages_to_keep(),
);
messages.extend(messages_to_load);
}
Ok(None) => {}
Err(e) => {
tracing::error!("Failed to read memory for step {}: {}", step_id, e);
}
}
}
}
HistorySource::Stateless => {}
}
}
// Extract previous step result only if any tool needs it
let previous_result = {
if any_tool_needs_previous_result(&tools) {
if let Some(ref flow_status) = flow_context.flow_status {
get_previous_job_result(db, &job.workspace_id, flow_status)
.await
.ok()
.flatten()
} else {
None
}
} else {
None
}
};
// Build IdContext for results.stepId syntax
let id_context = {
if let Some(ref flow_status) = flow_context.flow_status {
// Get the step ID from the AI agent's flow step
let previous_id = effective_flow_step_id
.map(str::to_string)
.unwrap_or_else(|| "unknown".to_string());
Some(get_transform_context(job, &previous_id, flow_status))
} else {
None
}
};
// Add user message and attachments as a single user message
// (Bedrock requires a text block alongside document blocks in the same message)
{
let has_message = args
.user_message
.as_ref()
.map(|m| !m.is_empty())
.unwrap_or(false);
let has_attachments = args
.user_attachments
.as_ref()
.map(|a| !a.is_empty())
.unwrap_or(false);
if has_message && has_attachments {
let mut parts = vec![ContentPart::Text { text: args.user_message.clone().unwrap() }];
for attachment in args.user_attachments.as_ref().unwrap() {
if !attachment.s3.is_empty() {
parts.push(ContentPart::S3Object { s3_object: attachment.clone() });
}
}
messages.push(OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::Parts(parts)),
..Default::default()
});
} else if has_message {
messages.push(OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::Text(args.user_message.clone().unwrap())),
..Default::default()
});
} else if has_attachments {
let mut parts = vec![];
for attachment in args.user_attachments.as_ref().unwrap() {
if !attachment.s3.is_empty() {
parts.push(ContentPart::S3Object { s3_object: attachment.clone() });
}
}
messages.push(OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::Parts(parts)),
..Default::default()
});
}
}
let mut messages = AgentHistory::new(messages);
let mut actions = vec![];
let mut content = None;
let mut final_usage: Option<TokenUsage> = None;
// Check if this provider supports tools with the current output type
let supports_tools = query_builder.supports_tools_with_output_type(output_type);
let mut tool_defs: Option<Vec<ToolDef>> = if tools.is_empty() || !supports_tools {
None
} else {
Some(tools.iter().map(|t| t.def.clone()).collect())
};
// Handle structured output schema
let has_output_properties = args
.output_schema
.as_ref()
.and_then(|schema| schema.properties.as_ref())
.map(|props| !props.is_empty())
.unwrap_or(false);
let should_use_structured_output_tool =
should_use_structured_output_tool(&args.provider.kind, &args.provider.model);
let mut used_structured_output_tool = false;
let mut structured_output_tool_name: Option<String> = None;
// For text output with schema, handle structured output
if has_output_properties && is_text_output {
let schema = args.output_schema.as_ref().unwrap();
if should_use_structured_output_tool {
// Anthropic uses a tool for structured output
let unique_tool_name = find_unique_tool_name("structured_output", tool_defs.as_deref());
structured_output_tool_name = Some(unique_tool_name.clone());
let output_tool = ToolDef {
r#type: "function".to_string(),
function: ToolDefFunction {
name: unique_tool_name,
description: Some(
"This tool MUST be used last to return a structured JSON object as the final output."
.to_string(),
),
parameters: to_raw_value(&schema),
},
};
if let Some(ref mut existing_tools) = tool_defs {
existing_tools.push(output_tool);
} else {
tool_defs = Some(vec![output_tool]);
}
}
// For non-Anthropic providers, response_format is handled by the query builder
}
let user_wants_streaming = streaming_requested(args.streaming);
*has_stream = user_wants_streaming && is_text_output;
let mut final_events_str = String::new();
let mut final_reasoning = String::new();
// Always create a StreamEventProcessor for text output (use silent mode if user doesn't want streaming)
let stream_event_processor = if is_text_output {
if user_wants_streaming {
Some(StreamEventProcessor::new(conn, job))
} else {
Some(StreamEventProcessor::new_silent())
}
} else {
None
};
let chat_enabled = flow_context
.flow_status
.as_ref()
.and_then(|fs| fs.chat_input_enabled)
.unwrap_or(false);
let persist_output_to_conversation = chat_enabled && !omit_output_from_conversation;
let step_name = get_step_name_from_flow(summary.as_deref(), effective_flow_step_id);
let max_iterations = args
.max_iterations
.map(|m| m.clamp(1, HARD_MAX_AGENT_ITERATIONS))
.unwrap_or(DEFAULT_MAX_AGENT_ITERATIONS);
let mut compactor = match &args.memory {
Some(Memory::Compaction { context_window }) if is_text_output => {
let tool_schema_tokens = tool_defs
.as_ref()
.and_then(|defs| serde_json::to_string(defs).ok())
.map(|schemas| schemas.len() / 4)
.unwrap_or(0);
// An unset window — the usual case — is looked up from the model. The field
// is the override for what the lookup cannot serve: a Custom AI deployment,
// or an id the table does not list.
let context_window = match context_window {
0 => model_context_window(args.provider.get_model()),
declared => *declared,
};
Some(Compactor::new(
context_window,
tool_schema_tokens,
args.max_completion_tokens,
))
}
_ => None,
};
// The store otherwise evicts oldest messages, including the summary. Bound only
// persisted context here; the running agent can still use the full model window.
let persist_capacity = match (&compactor, &history, effective_flow_step_id) {
(Some(_), HistorySource::Managed { .. }, Some(_)) => memory_storage_capacity_bytes().await,
_ => None,
};
// The summarization call runs under the agent's own request timeout, which resolves
// from the job alone and so is the same for every iteration.
let compaction_timeout = match compactor {
Some(_) => Some(
resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout)
.await
.0,
),
None => None,
};
// Held across iterations so an OIDC role is assumed once for the job and
// re-assumed only near expiry, rather than on every model turn.
#[cfg(feature = "bedrock")]
let mut bedrock_assumed_role: Option<windmill_ai::ai_bedrock::AssumedRoleCredentials> = None;
// Main agent loop
for i in 0..max_iterations {
// Check if parent was canceled — stop iterating but let current tool calls finish
if *cancel_rx.borrow() {
return Err(Error::ExecutionErr("Job cancelled".to_string()));
}
if used_structured_output_tool {
break;
}
compact_if_needed(
CompactionContext {
compactor: compactor.as_mut(),
timeout: compaction_timeout,
credentials: &credentials,
args,
client,
job,
conn,
},
query_builder.as_ref(),
include_usage,
&mut messages,
CompactionTrigger::Threshold,
&mut final_usage,
)
.await;
let mut retried_context = false;
let (parsed, request_message_count) = loop {
// How many messages this request carries, so the provider's prompt count can
// later be told apart from what the response and its tool results add.
let request_message_count = messages.context().len();
// Handle AWS Bedrock provider specially using the official SDK
let attempt: error::Result<_> = async {
let parsed = if credentials.provider == AIProvider::AWSBedrock {
#[cfg(feature = "bedrock")]
{
let region = credentials
.region
.as_deref()
.unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION);
// An OIDC role resolves to ordinary IAM keys, so from here the
// call is identical to the explicit-keys mode.
let assumed = windmill_ai::ai_bedrock::refresh_bedrock_oidc_credentials(
&credentials,
&mut bedrock_assumed_role,
client,
&job.id,
)
.await?;
let (access_key_id, secret_access_key, session_token) = match assumed {
Some(assumed) => (
Some(assumed.access_key_id.as_str()),
Some(assumed.secret_access_key.as_str()),
Some(assumed.session_token.as_str()),
),
None => (
credentials.aws_access_key_id.as_deref(),
credentials.aws_secret_access_key.as_deref(),
credentials.aws_session_token.as_deref(),
),
};
// Use Bedrock SDK via dedicated query builder
windmill_ai::providers::bedrock::BedrockQueryBuilder::default()
.execute_request(
messages.context(),
tool_defs.as_deref(),
args.provider.get_model(),
args.temperature,
args.provider.get_reasoning_effort(),
args.max_completion_tokens,
api_key,
region,
stream_event_processor.as_ref().map(|p| p.boxed_sink()),
client,
&job.workspace_id,
structured_output_tool_name.as_deref(),
access_key_id,
secret_access_key,
session_token,
)
.await?
}
#[cfg(not(feature = "bedrock"))]
{
return Err(Error::internal_err(
"AWS Bedrock support is not enabled. Build with 'bedrock' feature.".to_string(),
));
}
} else {
// For all other providers, use the HTTP client approach
let mut build_args = BuildRequestArgs {
messages: messages.context(),
tools: tool_defs.as_deref(),
model: args.provider.get_model(),
temperature: args.temperature,
reasoning_effort: args.provider.get_reasoning_effort(),
max_tokens: args.max_completion_tokens,
output_schema: args.output_schema.as_ref(),
output_type,
system_prompt: args.system_prompt.as_deref(),
user_message: args.user_message.as_deref().unwrap_or(""),
attachments: args.user_attachments.as_deref(),
has_websearch,
prompt_cache_key: include_prompt_cache_key.then_some(prompt_cache_key.as_str()),
reasoning_summary: !is_reasoning_summary_unavailable(
&credentials,
args.provider.get_model(),
),
};
// A worker cannot run the client credentials exchange, so an OAuth resource
// has no token here: the request would carry an empty credential and come
// back 401.
if needs_unavailable_oauth_exchange(
&credentials,
args.provider.resource.token_url.as_deref(),
&query_builder.get_auth_headers(api_key, base_url, output_type),
) {
return Err(Error::ExecutionErr(format!(
"The {:?} resource authenticates with OAuth, which AI agent steps do not \
support. Set an API key on the resource, or carry the provider's credential \
header in its `headers`.",
credentials.provider
)));
}
let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout)
.await
.0;
let trailing_headers = common_outbound_headers(&credentials).collect::<Vec<_>>();
// `endpoint` derives from the user-controlled provider base_url, so pin
// DNS to the SSRF-validated address: the connect must not rebind to an
// internal IP between the check and the request (TOCTOU).
let pinned_ai_client = pinned_ai_client_for(base_url).await?;
// Helper to build HTTP request with headers
let build_http_request =
|endpoint: &str, auth_headers: &[(&'static str, String)], body: String| {
let mut req = pinned_ai_client
.post(endpoint)
.timeout(timeout)
.header("Content-Type", "application/json");
for (header_name, header_value) in auth_headers {
req = req.header(*header_name, header_value.clone());
}
for (header_name, header_value) in &trailing_headers {
req = req.header(header_name.as_str(), header_value.as_str());
}
req.body(body)
};
// An endpoint can reject the request shape rather than the model:
// `stream_options` and `prompt_cache_key`, which not every OpenAI-compatible
// gateway accepts, a reasoning summary, which OpenAI refuses to unverified
// organizations, and the route itself, when an Azure resource is outside
// the Responses API's model/region matrix. Each is retried once with that
// part dropped.
// Set where the route is found to be absent, and read once the fallback has
// answered: a rejection it did not resolve says nothing about the deployment.
let mut rerouted_by_a_route_rejection = false;
let resp = loop {
let request_body = if include_usage {
query_builder
.build_request(&build_args, client, &job.workspace_id)
.await?
} else {
query_builder
.build_request_without_usage(&build_args, client, &job.workspace_id)
.await?
};
let endpoint =
query_builder.get_endpoint(base_url, args.provider.get_model(), output_type);
let auth_headers = retain_effective_credentials(
&credentials,
query_builder.get_auth_headers(api_key, base_url, output_type),
);
let resp = build_http_request(&endpoint, &auth_headers, request_body)
.send()
.await
.map_err(|e| Error::internal_err(format!("Failed to call API: {}", e)))?;
match resp.error_for_status_ref() {
Ok(_) => {
if rerouted_by_a_route_rejection {
remember_chat_completions_only(base_url, args.provider.get_model());
}
break resp;
}
Err(e) => {
let status = resp.status();
let text = resp
.text()
.await
.unwrap_or_else(|_| "<failed to read body>".to_string());
// Common error patterns: 400 Bad Request with mentions of stream_options or include_usage
let rejects_usage_tracking = include_usage
&& query_builder.supports_retry_without_usage()
&& status.as_u16() == 400
&& (text.contains("stream_options")
|| text.contains("include_usage")
|| text.contains("Additional properties are not allowed"));
// An OpenAI-compatible gateway that validates the body strictly
// names the offending field, whether it calls it an unrecognized
// argument or an unexpected additional property.
let rejects_prompt_cache_key = build_args.prompt_cache_key.is_some()
&& status.as_u16() == 400
&& text.contains("prompt_cache_key");
let summary_refused = build_args.reasoning_summary
&& rejects_reasoning_summary(status.as_u16(), &text);
// Only the first call of the step may re-route: an endpoint that
// does not serve this API rejects that one already, whereas a
// rejection once the conversation is under way is about the
// conversation (context length, content filter, tool schema).
let route_unserved = i == 0
&& !windmill_ai::query_builder::is_context_length_error(&text)
&& query_builder.supports_chat_completions_fallback(base_url)
&& matches!(status.as_u16(), 400 | 404)
&& *output_type == OutputType::Text;
if rejects_usage_tracking {
tracing::info!(
"Retrying request without stream_options due to provider incompatibility"
);
include_usage = false;
} else if rejects_prompt_cache_key {
// Checked before the route fallback: the endpoint serves this
// route, it just refuses one optional field, and re-routing
// the whole step over that would give up far more.
tracing::info!(
"Retrying request without prompt_cache_key due to provider incompatibility"
);
include_prompt_cache_key = false;
build_args.prompt_cache_key = None;
} else if summary_refused {
tracing::info!(
"Retrying request without the reasoning summary the endpoint refused"
);
remember_reasoning_summary_unavailable(
&credentials,
args.provider.get_model(),
);
build_args.reasoning_summary = false;
} else if route_unserved {
tracing::info!(
"Endpoint rejected the request ({}), falling back to chat/completions",
status
);
// Only a 404 says the route is absent. A 400 is ambiguous —
// a deployment that does serve the route rejects tool
// schemas, blocked hosted tools and filtered content the
// same way — so it re-routes this step and nothing more.
rerouted_by_a_route_rejection = status.as_u16() == 404;
query_builder = create_chat_completions_query_builder(&credentials);
include_usage = true;
} else {
return Err(Error::internal_err(format!(
"API error calling {}: {} - {}",
endpoint, e, text
)));
}
}
}
};
if let Some(ref stream_event_processor) = stream_event_processor {
query_builder
.parse_streaming_response(resp, stream_event_processor.boxed_sink())
.await?
} else {
query_builder.parse_image_response(resp).await?
}
};
Ok(parsed)
}.await;
match attempt {
Ok(parsed) => break (parsed, request_message_count),
Err(error)
if !retried_context
&& compactor.is_some()
&& windmill_ai::query_builder::is_context_length_error(
&error.to_string(),
) =>
{
retried_context = true;
append_logs(
&job.id,
&job.workspace_id,
"Provider rejected the context size; attempting compaction before one retry.\n".to_string(),
conn,
).await;
let changed = compact_if_needed(
CompactionContext {
compactor: compactor.as_mut(),
timeout: compaction_timeout,
credentials: &credentials,
args,
client,
job,
conn,
},
query_builder.as_ref(),
include_usage,
&mut messages,
CompactionTrigger::ContextRejected,
&mut final_usage,
)
.await;
if !changed {
append_logs(
&job.id,
&job.workspace_id,
"Context compaction could not produce a smaller usable request; returning the provider error without retry.\n".to_string(),
conn,
).await;
return Err(error);
}
}
Err(error) => return Err(error),
}
};
match parsed {
ParsedResponse::Text {
content: response_content,
reasoning: response_reasoning,
tool_calls,
events_str,
annotations,
used_websearch,
usage,
} => {
messages.record_usage(
usage.as_ref().and_then(|u| u.input_tokens),
request_message_count,
);
// Accumulate usage from this iteration
if let Some(u) = usage {
match &mut final_usage {
Some(existing) => existing.accumulate(&u),
None => final_usage = Some(u),
}
}
if let Some(events_str) = events_str {
final_events_str.push_str(&events_str);
}
append_reasoning(&mut final_reasoning, response_reasoning.as_deref());
// Add websearch tool message if websearch was used
if used_websearch {
actions.push(AgentAction::WebSearch {});
if let Some(parent_job) = parent_job {
update_flow_status_module_with_actions(db, parent_job, &actions).await?;
update_flow_status_module_with_actions_success(db, parent_job, true)
.await?;
}
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(
"Used websearch tool successfully".to_string(),
)),
agent_action: Some(AgentAction::WebSearch {}),
..Default::default()
});
if persist_output_to_conversation {
if let Some(conversation_id) = conversation_id {
// The search ran inside the provider's call, so this job's args
// describe the agent, not the search: its sources reach the row
// only if they are written here.
let extras = (!annotations.is_empty()).then(|| MessageExtras {
tool_result: serde_json::to_string(&annotations).ok(),
..Default::default()
});
// Awaited like every row of the loop, so rows commit in turn order.
// Worded like every other tool row, so a reader recovers the tool
// name from the sentence.
if let Err(e) = add_message_to_conversation(
db,
&conversation_id,
Some(job.id),
"Used websearch tool",
MessageType::Tool,
&step_name,
true,
extras.as_ref(),
)
.await
{
tracing::warn!(
"Failed to add websearch tool message to conversation {}: {}",
conversation_id,
e
);
}
}
}
}
if let Some(ref response_content) = response_content {
actions.push(AgentAction::Message {});
messages.push(OpenAIMessage {
role: "assistant".to_string(),
content: Some(OpenAIContent::Text(response_content.clone())),
agent_action: Some(AgentAction::Message {}),
annotations: if annotations.is_empty() {
None
} else {
Some(annotations.clone())
},
..Default::default()
});
if let Some(parent_job) = parent_job {
update_flow_status_module_with_actions(db, parent_job, &actions).await?;
update_flow_status_module_with_actions_success(db, parent_job, true)
.await?;
}
content = Some(OpenAIContent::Text(response_content.clone()));
// Add assistant message to conversation if chat_input_enabled
if persist_output_to_conversation && !response_content.is_empty() {
if let Some(conversation_id) = conversation_id {
// This iteration's thinking goes on the answer's row; the job
// result only keeps the turn's thinking as one string.
let extras = response_reasoning.clone().map(|reasoning| {
MessageExtras { reasoning: Some(reasoning), ..Default::default() }
});
if let Err(e) = add_message_to_conversation(
db,
&conversation_id,
Some(job.id),
response_content,
MessageType::Assistant,
&step_name,
true,
extras.as_ref(),
)
.await
{
tracing::warn!(
"Failed to add assistant message to conversation {}: {}",
conversation_id,
e
);
}
}
}
}
if tool_calls.is_empty() {
break;
} else if i == max_iterations - 1 {
#[derive(serde::Serialize)]
struct MaxIterError<'a> {
message: String,
name: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
step_id: Option<&'a str>,
result: AgentPartialResult<'a>,
}
return Err(Error::ExecutionRawError(
serde_json::value::to_raw_value(&MaxIterError {
message: format!(
"AI agent reached max iterations ({}), you can either increase max_iterations or enable the \"continue on error\" option from the advanced options of the step.",
max_iterations
),
name: "ExecutionErr",
step_id: effective_flow_step_id,
result: AgentPartialResult {
messages: messages
.result()
.iter()
.map(|m| Message {
message: m,
agent_action: m.agent_action.as_ref(),
})
.collect(),
},
})?,
));
}
messages.push(OpenAIMessage {
role: "assistant".to_string(),
tool_calls: Some(tool_calls.clone()),
..Default::default()
});
// A round's thinking is stored on one row, the first the round writes, which is
// where the stream shows it: its text row when it wrote text, else the row of
// its first call — the answer row below when that call is the structured-output
// tool. Two rows carrying it would show it twice after a reload.
let call_reasoning = response_reasoning
.clone()
.filter(|_| response_content.as_deref().unwrap_or("").is_empty());
let structured_output_first = structured_output_tool_name
.as_ref()
.zip(tool_calls.first())
.map_or(false, |(name, tc)| tc.function.name == *name);
// Handle tool calls using extracted tools module
let tool_execution_ctx = ToolExecutionContext {
db,
conn,
job,
parent_job,
summary: &summary,
flow_step_id_override,
client,
worker_dir,
base_internal_url,
worker_name,
hostname,
occupancy_metrics,
killpill_rx,
stream_event_processor: stream_event_processor.as_ref(),
flow_context: &mut flow_context,
omit_output_from_conversation,
reasoning: if structured_output_first {
None
} else {
call_reasoning.clone()
},
previous_result: &previous_result,
id_context: &id_context,
tool_abort_handles: tool_abort_handles.clone(),
};
let (tool_messages, tool_content, tool_used_structured_output) =
execute_tool_calls(
tool_execution_ctx,
&tool_calls,
&tools,
mcp_clients,
&mut actions,
&mut final_events_str,
&structured_output_tool_name,
)
.await?;
messages.extend(tool_messages);
// A structured answer is the arguments of the structured-output tool call,
// on which the loop ends without a text iteration, so its row is written here.
if tool_used_structured_output && persist_output_to_conversation {
if let (Some(conversation_id), Some(OpenAIContent::Text(answer))) =
(conversation_id, tool_content.as_ref())
{
let extras = call_reasoning
.clone()
.filter(|_| structured_output_first)
.map(|reasoning| MessageExtras {
reasoning: Some(reasoning),
..Default::default()
});
if let Err(e) = add_message_to_conversation(
db,
&conversation_id,
Some(job.id),
answer,
MessageType::Assistant,
&step_name,
true,
extras.as_ref(),
)
.await
{
tracing::warn!(
"Failed to add structured answer to conversation {}: {}",
conversation_id,
e
);
}
}
}
if let Some(tc) = tool_content {
content = Some(tc);
}
used_structured_output_tool = tool_used_structured_output;
// Check cancellation after tool calls complete to avoid a wasted LLM call
if *cancel_rx.borrow() {
return Err(Error::ExecutionErr("Job cancelled".to_string()));
}
}
ParsedResponse::Image { base64_data } => {
// For image output, upload to S3 and track in conversation
let s3_object =
upload_image_to_s3(&base64_data, &job.workspace_id, &job.id, client).await?;
let content = to_raw_value(&s3_object);
// Add assistant message to conversation if chat_input_enabled
if persist_output_to_conversation {
if let Some(conversation_id) = conversation_id {
// Create extended version with type discriminator for conversation storage
// This avoids conflicts with outputs that are of the same format as S3 objects
let s3_with_type = S3ObjectWithType {
s3_object: s3_object.clone(),
r#type: "windmill_s3_object".to_string(),
};
let message_content = serde_json::to_string(&s3_with_type)
.unwrap_or_else(|_| content.get().to_string());
if let Err(e) = add_message_to_conversation(
db,
&conversation_id,
Some(job.id),
&message_content,
MessageType::Assistant,
&step_name,
true,
None,
)
.await
{
tracing::warn!(
"Failed to add assistant message to conversation {}: {}",
conversation_id,
e
);
}
}
}
// Return early since image generation is complete
return Ok(content);
}
}
}
if let Some(capacity_bytes) =
persist_capacity.filter(|limit| persisted_bytes(messages.context()) > *limit)
{
compact_if_needed(
CompactionContext {
compactor: compactor.as_mut(),
timeout: compaction_timeout,
credentials: &credentials,
args,
client,
job,
conn,
},
query_builder.as_ref(),
include_usage,
&mut messages,
CompactionTrigger::Storage { capacity_bytes },
&mut final_usage,
)
.await;
}
let memory_to_save = match persist_capacity {
Some(limit) => memory_within_capacity(messages.context(), limit),
None => Some(messages.context()),
};
if memory_to_save.is_some_and(|tail| tail.len() < messages.context().len()) {
append_logs(&job.id, &job.workspace_id,
"AI agent memory exceeds storage capacity after compaction; saving only the newest complete exchanges. Older memory was dropped; the answer and run action results are preserved.\n".to_string(), conn).await;
} else if memory_to_save.is_none() {
append_logs(&job.id, &job.workspace_id,
"AI agent memory has no complete user-starting conversation that fits storage capacity and memory was not saved. The answer is preserved; the next run will load the previous saved memory.\n".to_string(), conn).await;
}
// Return the final result
let final_messages: Vec<Message> = messages
.result()
.iter()
.map(|m| Message { message: m, agent_action: m.agent_action.as_ref() })
.collect();
// Parse content as JSON for structured output, fallback to string if it fails
let output_value = match content {
Some(content_str) => match has_output_properties {
true => match content_str {
OpenAIContent::Text(text) => {
serde_json::from_str::<Box<RawValue>>(&text).map_err(|_e| {
Error::internal_err(format!("Failed to parse structured output: {}", text))
})
}
OpenAIContent::Parts(_parts) => Err(Error::internal_err(
"Failed to parse structured output".to_string(),
)),
},
false => Ok(match content_str {
OpenAIContent::Text(text) => to_raw_value(&text),
OpenAIContent::Parts(parts) => to_raw_value(&parts),
}),
}?,
None => to_raw_value(&""),
};
// Wait for stream event processor to finish persisting events (if any)
if let Some(handle) = {
if let Some(stream_event_processor) = stream_event_processor {
stream_event_processor.to_handle()
} else {
None
}
} {
if let Err(e) = handle.await {
return Err(Error::internal_err(format!(
"Error waiting for stream event processor: {}",
e
)));
}
}
// Storage limits affect only the saved suffix, never the returned execution history.
if let Some(memory_to_save) = memory_to_save.filter(|_| matches!(output_type, OutputType::Text))
{
if let HistorySource::Managed { memory_id, bound } = &history {
if let Some(step_id) = effective_flow_step_id {
if !memory_to_save.is_empty() {
let messages_to_persist = prepare_auto_memory_messages_for_persistence(
memory_to_save,
bound.messages_to_keep(),
);
match write_to_memory(
db,
&job.workspace_id,
*memory_id,
step_id,
&messages_to_persist,
matches!(bound, MemoryBound::LastMessages(_)),
)
.await
{
// The conversation outgrew what the database holds and was cut
// from its oldest message. Only the worker's own log says so
// otherwise, so from the flow's side the agent simply starts the
// next run having forgotten how this one began.
Ok(dropped) if dropped > 0 => {
append_logs(
&job.id,
&job.workspace_id,
format!(
"Memory does not fit the {}KB the database holds, so its \
{dropped} oldest messages were dropped. Configure instance \
object storage to keep the whole conversation.\n",
MAX_MEMORY_SIZE_BYTES / 1000
),
conn,
)
.await;
}
Ok(_) => {}
Err(e) => {
tracing::error!(
"Failed to persist {} messages to memory for step {}: {}",
messages_to_persist.len(),
step_id,
e
);
append_logs(&job.id, &job.workspace_id,
"Agent memory could not be saved. The answer is preserved; the next run may load older memory.\n".to_string(), conn).await;
}
}
}
}
}
}
Ok(to_raw_value(&AIAgentResult {
output: output_value,
messages: final_messages,
wm_stream: if !final_events_str.is_empty() {
Some(final_events_str)
} else {
None
},
reasoning: (!final_reasoning.is_empty()).then_some(final_reasoning),
usage: if final_usage.as_ref().map(|u| u.is_empty()).unwrap_or(true) {
None
} else {
final_usage
},
}))
}
/// 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.
///
/// The chat surfaces decide whether to open a stream from their own reading of the same config,
/// and a surface that opens one for an answer sent in a single piece re-runs the flow when the
/// connection times out. So this default is half of a contract, not a local preference.
fn streaming_requested(streaming: Option<bool>) -> bool {
streaming.unwrap_or(true)
}
/// Add one iteration's thinking to the step's. Every iteration thinks, and a tool-call
/// iteration's thinking is what led to the call, so the result keeps all of them in order,
/// blank-line separated, rather than only the answering turn's.
fn append_reasoning(accumulated: &mut String, reasoning: Option<&str>) {
let Some(reasoning) = reasoning.map(str::trim).filter(|r| !r.is_empty()) else {
return;
};
if !accumulated.is_empty() {
accumulated.push_str("\n\n");
}
accumulated.push_str(reasoning);
}
#[cfg(test)]
mod tests {
use super::*;
fn text_message(role: &str, content: &str) -> OpenAIMessage {
OpenAIMessage {
role: role.to_string(),
content: Some(OpenAIContent::Text(content.to_string())),
..Default::default()
}
}
#[derive(Debug, PartialEq)]
enum Resolved {
Messages(usize),
Window(Uuid, usize),
Compaction(Uuid, usize),
Stateless { noted: bool },
}
/// Every memory shape a worker may still read, resolved against a run with or without a
/// memory id. The hashed id is pinned: changing it detaches memories stored under string ids.
#[test]
fn history_source_resolves_every_memory_shape() {
use serde_json::json;
let run = Uuid::from_u128(1);
let baked = Uuid::from_u128(2);
let cust_1 = Uuid::parse_str("0168fcea-ffa7-5c15-bdb0-7709bb5f540d").unwrap();
let window = json!({ "kind": "window", "context_length": 10 });
let message = json!([{ "role": "user", "content": "earlier" }]);
let two_messages = json!([
{ "role": "user", "content": "earlier" },
{ "role": "assistant", "content": "reply" }
]);
let cases = [
(
"absent memory is off",
json!({}),
Some(run),
Resolved::Stateless { noted: false },
),
(
"legacy off",
json!({ "memory": { "kind": "off" } }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"legacy auto prefers the run's id",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked } }),
Some(run),
Resolved::Window(run, 4),
),
(
"legacy auto falls back to its baked id",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked } }),
None,
Resolved::Window(baked, 4),
),
(
"legacy auto with an empty baked id uses the run's",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": "" } }),
Some(run),
Resolved::Window(run, 4),
),
(
"legacy auto with an empty baked id and no run id is stateless",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": " " } }),
None,
Resolved::Stateless { noted: true },
),
(
"legacy auto without a length is off",
json!({ "memory": { "kind": "auto", "memory_id": baked } }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"a cleared count is off",
json!({ "memory": { "kind": "window", "context_length": null } }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"legacy manual replays its messages",
json!({ "memory": { "kind": "manual", "messages": message } }),
Some(run),
Resolved::Messages(1),
),
(
"window keeps the run's memory",
json!({ "memory": window }),
Some(run),
Resolved::Window(run, 10),
),
(
"window without a memory id is stateless",
json!({ "memory": window }),
None,
Resolved::Stateless { noted: true },
),
(
"compaction keeps the run's memory",
json!({ "memory": { "kind": "compaction", "context_window": 32000 } }),
Some(run),
Resolved::Compaction(run, 32000),
),
(
"a cleared context window is left for the model lookup to fill",
json!({ "memory": { "kind": "compaction", "context_window": null } }),
Some(run),
Resolved::Compaction(run, 0),
),
(
"a step memory id overrides the run's",
json!({ "memory": window, "memory_id": "cust_1" }),
Some(run),
Resolved::Window(cust_1, 10),
),
(
"a uuid step memory id is used as is",
json!({ "memory": window, "memory_id": baked.to_string() }),
Some(run),
Resolved::Window(baked, 10),
),
(
"a step memory id evaluating to null is stateless",
json!({ "memory": window, "memory_id": null }),
Some(run),
Resolved::Stateless { noted: true },
),
(
"an off policy ignores the step memory id, and says so",
json!({ "memory": { "kind": "off" }, "memory_id": "cust_1" }),
Some(run),
Resolved::Stateless { noted: true },
),
(
"managed memory ignores the step's previous messages",
json!({ "memory": window, "memory_id": "cust_1", "previous_messages": message }),
Some(run),
Resolved::Window(cust_1, 10),
),
(
"memory that is off sends the step's previous messages",
json!({ "previous_messages": message }),
Some(run),
Resolved::Messages(1),
),
(
"a previous messages expression that evaluated to null is no history",
json!({ "previous_messages": null }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"a legacy manual list ignores the step's previous messages",
json!({ "memory": { "kind": "manual", "messages": message }, "previous_messages": two_messages }),
Some(run),
Resolved::Messages(1),
),
(
"legacy auto ignores a step memory id",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked }, "memory_id": "cust_1" }),
None,
Resolved::Window(baked, 4),
),
];
for (name, history, run_memory_id, expected) in cases {
let mut raw = json!({ "provider": { "kind": "openai", "resource": {}, "model": "m" } });
raw.as_object_mut()
.unwrap()
.extend(history.as_object().unwrap().clone());
let args: AIAgentArgs = serde_json::from_value(raw).unwrap();
let resolved = match resolve_history_source(&args, run_memory_id, "ws", "f/flow") {
(HistorySource::Messages(m), _) => Resolved::Messages(m.len()),
(
HistorySource::Managed {
memory_id,
bound: MemoryBound::LastMessages(context_length),
},
_,
) => Resolved::Window(memory_id, context_length),
(HistorySource::Managed { memory_id, bound: MemoryBound::Compaction }, _) => {
match args.memory {
Some(Memory::Compaction { context_window }) => {
Resolved::Compaction(memory_id, context_window)
}
_ => panic!("expected compaction settings"),
}
}
(HistorySource::Stateless, notes) => {
Resolved::Stateless { noted: !notes.is_empty() }
}
};
assert_eq!(resolved, expected, "{name}");
}
}
/// A placeholder the form seeds must not read as a memory id that evaluated to nothing, which
/// would turn memory off for the step.
#[test]
fn only_an_expression_can_set_an_empty_step_memory_id() {
let transforms = |memory_id: &str| -> HashMap<String, InputTransform> {
HashMap::from([(
"memory_id".to_string(),
serde_json::from_str(memory_id).unwrap(),
)])
};
let args = || -> AIAgentArgs {
serde_json::from_value(serde_json::json!({
"provider": { "kind": "openai", "resource": {}, "model": "m" },
"memory_id": null,
}))
.unwrap()
};
for (transform, expected) in [
(r#"{ "type": "static" }"#, None),
(r#"{ "type": "static", "value": "" }"#, None),
(r#"{ "type": "ai" }"#, None),
(
r#"{ "type": "javascript", "expr": "flow_input.customer_id" }"#,
Some(""),
),
] {
let mut args = args();
keep_authored_memory_id(&mut args, &transforms(transform));
assert_eq!(args.memory_id.as_deref(), expected, "{transform}");
}
}
/// Only text output sends previous messages, so they never stand in for an image prompt.
#[test]
fn previous_messages_never_stand_in_for_an_image_prompt() {
let args: AIAgentArgs = serde_json::from_value(serde_json::json!({
"provider": { "kind": "openai", "resource": {}, "model": "m" },
"previous_messages": [{ "role": "user", "content": "earlier" }],
}))
.unwrap();
let (history, _) = resolve_history_source(&args, None, "ws", "f/flow");
assert!(has_prompt(&history, false, true, false));
assert!(!has_prompt(&history, false, false, false));
assert!(has_prompt(&history, true, false, false));
assert!(!has_prompt(
&HistorySource::Messages(&[]),
false,
true,
false
));
// A legacy `manual` memory ran on an empty list alone, and still does for text output.
assert!(has_prompt(&HistorySource::Messages(&[]), false, true, true));
assert!(!has_prompt(
&HistorySource::Messages(&[]),
false,
false,
true
));
}
#[test]
fn reasoning_keeps_every_iteration_in_order() {
let mut acc = String::new();
append_reasoning(&mut acc, Some("I need both cities.\n\n"));
append_reasoning(&mut acc, None);
append_reasoning(&mut acc, Some(" "));
append_reasoning(&mut acc, Some("Paris is closer."));
assert_eq!(acc, "I need both cities.\n\nParis is closer.");
}
#[test]
fn an_unwritten_streaming_field_streams() {
assert!(streaming_requested(None));
assert!(streaming_requested(Some(true)));
assert!(!streaming_requested(Some(false)));
}
#[test]
fn max_iterations_partial_result_keeps_the_action_tags() {
let messages = vec![OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text("{\"rows\":2}".to_string())),
tool_call_id: Some("call_1".to_string()),
agent_action: Some(AgentAction::ToolCall {
job_id: uuid::Uuid::nil(),
function_name: "list_payouts".to_string(),
module_id: "b".to_string(),
}),
..Default::default()
}];
let partial = AgentPartialResult {
messages: messages
.iter()
.map(|m| Message { message: m, agent_action: m.agent_action.as_ref() })
.collect(),
};
let json = serde_json::to_value(&partial).unwrap();
let action = &json["messages"][0]["agent_action"];
assert_eq!(action["type"], "tool_call");
assert_eq!(action["function_name"], "list_payouts");
}
/// Over 64 characters OpenAI rejects the key outright, which costs a wasted round
/// trip per run and silently leaves that step with no prompt caching at all.
#[test]
fn prompt_cache_key_stays_within_the_provider_bound() {
let long = format!("my-workspace:f/{}/agent:step_12", "nested_folder".repeat(8));
assert!(long.len() > 64);
let bounded = bounded_prompt_cache_key(&long);
assert!(
bounded.len() <= 64,
"got {} chars: {bounded}",
bounded.len()
);
// Stable for the same step, or every run would land on a different cache.
assert_eq!(bounded, bounded_prompt_cache_key(&long));
assert_ne!(
bounded,
bounded_prompt_cache_key(&long.replace("step_12", "step_13"))
);
}
#[test]
fn prompt_cache_key_passes_short_keys_through_unchanged() {
let short = "admins:f/agent/step:a";
assert_eq!(bounded_prompt_cache_key(short), short);
}
/// Truncation on a byte index would panic mid-character.
#[test]
fn prompt_cache_key_truncates_on_a_char_boundary() {
let long = format!("workspace:f/{}/agent:step", "é".repeat(80));
assert!(bounded_prompt_cache_key(&long).len() <= 64);
}
#[test]
fn overlay_tool_inputs_binds_matching_flowmodule_tool_only() {
fn js(expr: &str) -> InputTransform {
InputTransform::Javascript { expr: expr.to_string() }
}
fn script_tool(id: &str, key: &str, expr: &str) -> AgentTool {
let mut its = HashMap::new();
its.insert(key.to_string(), js(expr));
AgentTool {
id: id.to_string(),
summary: None,
description: None,
value: ToolValue::FlowModule(FlowModuleValue::Script {
input_transforms: its,
path: "u/test/tool".to_string(),
hash: None,
tag_override: None,
is_trigger: None,
pass_flow_input_directly: None,
}),
}
}
fn script_its(tool: &AgentTool) -> &HashMap<String, InputTransform> {
let ToolValue::FlowModule(FlowModuleValue::Script { input_transforms, .. }) =
&tool.value
else {
panic!("expected script tool")
};
input_transforms
}
// "a" gets rebound, "b" is left alone, the MCP tool is skipped even though it has an override.
let mut tools = vec![
script_tool("a", "x", "authoring_flow_expr"),
script_tool("b", "y", "keep_me"),
AgentTool {
id: "m".to_string(),
summary: None,
description: None,
value: ToolValue::Mcp(windmill_common::flows::McpToolValue {
resource_path: "u/test/mcp".to_string(),
include_tools: vec![],
exclude_tools: vec![],
}),
},
];
let mut tool_inputs: HashMap<String, HashMap<String, InputTransform>> = HashMap::new();
tool_inputs.insert(
"a".to_string(),
HashMap::from([
("x".to_string(), js("flow_input.tenant")),
("z".to_string(), js("results.step1")),
]),
);
tool_inputs.insert(
"m".to_string(),
HashMap::from([("q".to_string(), js("ignored"))]),
);
overlay_tool_inputs(&mut tools, &tool_inputs);
// "a": existing key replaced, new key added.
let a = script_its(&tools[0]);
assert!(
matches!(a.get("x"), Some(InputTransform::Javascript { expr }) if expr == "flow_input.tenant")
);
assert!(
matches!(a.get("z"), Some(InputTransform::Javascript { expr }) if expr == "results.step1")
);
// "b": no override for it, untouched.
let b = script_its(&tools[1]);
assert!(
matches!(b.get("y"), Some(InputTransform::Javascript { expr }) if expr == "keep_me")
);
// MCP tool: not a FlowModule, left as-is.
assert!(matches!(&tools[2].value, ToolValue::Mcp(_)));
}
#[test]
fn narrow_roster_keeps_the_entries_a_run_named() {
fn named(id: &str, summary: &str) -> AgentTool {
AgentTool {
id: id.to_string(),
summary: Some(summary.to_string()),
description: None,
value: ToolValue::FlowModule(FlowModuleValue::Script {
input_transforms: HashMap::new(),
path: "u/test/tool".to_string(),
hash: None,
tag_override: None,
is_trigger: None,
pass_flow_input_directly: None,
}),
}
}
fn mcp(id: &str, summary: &str, path: &str) -> AgentTool {
AgentTool {
id: id.to_string(),
summary: Some(summary.to_string()),
description: None,
value: ToolValue::Mcp(windmill_common::flows::McpToolValue {
resource_path: path.to_string(),
include_tools: vec![],
exclude_tools: vec![],
}),
}
}
fn websearch(id: &str, summary: Option<&str>) -> AgentTool {
AgentTool {
id: id.to_string(),
summary: summary.map(str::to_string),
description: None,
value: ToolValue::Websearch(windmill_common::flows::WebsearchToolValue {}),
}
}
let roster = || {
vec![
named("a", "get_user"),
named("b", "send_email"),
mcp("c", "github", "$res:u/test/gh"),
]
};
let names = |tools: &[AgentTool]| -> Vec<String> {
tools.iter().filter_map(|t| t.summary.clone()).collect()
};
let ids =
|tools: &[AgentTool]| -> Vec<String> { tools.iter().map(|t| t.id.clone()).collect() };
// No list at all: the whole roster, as every agent written before the field expects.
assert_eq!(
names(&narrow_roster(roster(), None)),
["get_user", "send_email", "github"]
);
// An empty list is a list: nothing is advertised, and no server is resolved to find that
// out — one that is down must not fail a run that switched it off.
assert!(names(&narrow_roster(roster(), Some(&[]))).is_empty());
let enabled = ["get_user".to_string(), "renamed_away".to_string()];
assert_eq!(
names(&narrow_roster(roster(), Some(&enabled))),
["get_user"]
);
// Counted, not quoted: a name is an argument value, and one holding `$var:` arrives as the
// variable's own value, which this log is not masked for.
assert_eq!(
unmatched_enabled_tools_message(&enabled, &["get_user", "send_email", "u/test/gh"])
.unwrap(),
"--- ENABLED TOOLS: 1 name named no tool of this agent and had no effect ---\n"
);
// A server is named by the resource it points at, bare, and never by its summary: that
// label is shown to nobody and two entries may carry the same one.
let named_server = ["u/test/gh".to_string()];
assert_eq!(
names(&narrow_roster(roster(), Some(&named_server))),
["github"]
);
assert!(unmatched_enabled_tools_message(&named_server, &["u/test/gh"]).is_none());
// Alongside a name that does match, so the summary being rejected is what empties it.
let summary_and_tool = ["get_user".to_string(), "github".to_string()];
assert_eq!(
names(&narrow_roster(roster(), Some(&summary_and_tool))),
["get_user"]
);
assert!(narrow_roster(roster(), Some(&["u/test/other".to_string()])).is_empty());
// Web search reaches the model as a provider capability rather than a tool, so it has no
// name of its own and is enabled by a reserved one, whatever label it was authored with.
for label in [None, Some("Web Search")] {
let mut with_websearch = roster();
with_websearch.push(websearch("w", label));
assert_eq!(
ids(&narrow_roster(
with_websearch,
Some(&[WEBSEARCH_ENABLED_NAME.to_string()])
)),
["w"]
);
}
}
#[test]
fn a_tool_cannot_take_the_name_web_search_is_enabled_by() {
// Nothing else in a roster may answer to the reserved name, or enabling that tool would
// switch web search on beside it. Held here rather than by the shape of the name, which is
// an ordinary identifier: the run refuses to start instead.
assert!(TOOL_NAME_REGEX.is_match(WEBSEARCH_ENABLED_NAME));
assert!(flow_module_tool_name(Some(WEBSEARCH_ENABLED_NAME)).is_err());
assert_eq!(flow_module_tool_name(Some("get_user")).unwrap(), "get_user");
assert!(flow_module_tool_name(Some("get user")).is_err());
assert!(flow_module_tool_name(None).is_err());
}
#[test]
fn tool_description_prefers_explicit_over_derived_and_name() {
assert_eq!(
resolve_tool_description(
Some(" Use to look up a user by id ".to_string()),
Some("derived from script".to_string()),
"get_user"
),
"Use to look up a user by id"
);
}
#[test]
fn tool_description_falls_back_to_derived_when_no_explicit() {
assert_eq!(
resolve_tool_description(None, Some("Sync resources".to_string()), "sync_tool"),
"Sync resources"
);
// A blank explicit description must not shadow a usable derived one.
assert_eq!(
resolve_tool_description(
Some(" ".to_string()),
Some("Sync resources".to_string()),
"sync_tool"
),
"Sync resources"
);
}
#[test]
fn tool_description_falls_back_to_name_when_nothing_usable() {
assert_eq!(resolve_tool_description(None, None, "my_tool"), "my_tool");
assert_eq!(
resolve_tool_description(Some(" ".to_string()), Some("".to_string()), "my_tool"),
"my_tool"
);
}
#[test]
fn auto_memory_request_preserves_messages_within_context_window() {
let loaded_messages = vec![
text_message("system", "instructions-a"),
text_message("user", "first-user"),
text_message("assistant", "first-assistant"),
text_message("system", "instructions-b"),
text_message("user", "second-user"),
text_message("assistant", "second-assistant"),
];
let prepared = prepare_auto_memory_messages_for_request(&loaded_messages, 3);
let roles: Vec<&str> = prepared
.iter()
.map(|message| message.role.as_str())
.collect();
let contents: Vec<&str> = prepared
.iter()
.map(|message| match message.content.as_ref() {
Some(OpenAIContent::Text(text)) => text.as_str(),
_ => "",
})
.collect();
assert_eq!(roles, vec!["system", "user", "assistant"]);
assert_eq!(
contents,
vec!["instructions-b", "second-user", "second-assistant"]
);
}
#[test]
fn auto_memory_request_drops_leading_tool_messages() {
let loaded_messages = vec![
text_message("tool", "stale-tool-result"),
text_message("user", "hello"),
text_message("assistant", "hi"),
];
let prepared = prepare_auto_memory_messages_for_request(&loaded_messages, 10);
let roles: Vec<&str> = prepared
.iter()
.map(|message| message.role.as_str())
.collect();
assert_eq!(roles, vec!["user", "assistant"]);
}
#[test]
fn auto_memory_persistence_excludes_system_messages() {
let all_messages = vec![
text_message("system", "instructions"),
text_message("user", "hello"),
text_message("assistant", "hi"),
text_message("system", "duplicate-instructions"),
text_message("user", "follow-up"),
];
let persisted = prepare_auto_memory_messages_for_persistence(&all_messages, 10);
let roles: Vec<&str> = persisted
.iter()
.map(|message| message.role.as_str())
.collect();
assert_eq!(roles, vec!["user", "assistant", "user"]);
}
}
/// Handle credentials check mode - check credentials without making API calls
async fn handle_credentials_check(provider: &ProviderWithResource) -> Result<Box<RawValue>, Error> {
let result = match &provider.kind {
#[cfg(feature = "bedrock")]
AIProvider::AWSBedrock => {
let check = check_env_credentials().await;
serde_json::json!({
"credentials_check": true,
"provider": "aws_bedrock",
"credentials": {
"available": check.available,
"access_key_id_prefix": check.access_key_id_prefix,
"region": check.region,
"error": check.error
}
})
}
#[cfg(not(feature = "bedrock"))]
AIProvider::AWSBedrock => {
serde_json::json!({
"credentials_check": true,
"provider": "aws_bedrock",
"error": "AWS Bedrock support is not enabled. Build with 'bedrock' feature."
})
}
other => {
serde_json::json!({
"credentials_check": true,
"provider": format!("{:?}", other),
"message": "Credentials check not implemented for this provider"
})
}
};
serde_json::value::to_raw_value(&result).map_err(|e| Error::internal_err(e.to_string()))
}
/// Hard-timeout fallback: force-cancel any descendant jobs still in v2_job_queue
/// so they don't stay as zombies.
async fn cleanup_orphaned_tool_jobs(
db: &DB,
parent_job_id: &Uuid,
w_id: &str,
canceled_by: Option<CanceledBy>,
) {
let username = canceled_by
.as_ref()
.and_then(|cb| cb.username.clone())
.unwrap_or_else(|| "unknown".to_string());
let reason = canceled_by
.as_ref()
.and_then(|cb| cb.reason.clone())
.unwrap_or_else(|| {
format!(
"parent AI agent {} was cancelled and tool call did not complete in time",
parent_job_id
)
});
// Find direct child jobs still in v2_job_queue (agent tool jobs are always direct children)
let orphaned_ids: Vec<Uuid> = match sqlx::query_scalar!(
r#"SELECT j.id FROM v2_job j
JOIN v2_job_queue q ON q.id = j.id
WHERE j.parent_job = $1 AND j.workspace_id = $2"#,
parent_job_id,
w_id,
)
.fetch_all(db)
.await
{
Ok(ids) => ids,
Err(e) => {
tracing::error!(
"Failed to find orphaned tool jobs for {}: {}",
parent_job_id,
e
);
return;
}
};
if orphaned_ids.is_empty() {
return;
}
tracing::warn!(
"Cleaning up {} orphaned tool jobs for cancelled AI agent {}",
orphaned_ids.len(),
parent_job_id,
);
for job_id in &orphaned_ids {
let queued_job = match windmill_queue::get_queued_job_v2(db, job_id).await {
Ok(Some(j)) => j,
Ok(None) => continue,
Err(e) => {
tracing::error!("Failed to fetch orphaned tool job {}: {}", job_id, e);
continue;
}
};
let tx = match db.begin().await {
Ok(tx) => tx,
Err(e) => {
tracing::error!(
"Failed to begin transaction for orphaned job {}: {}",
job_id,
e
);
continue;
}
};
match cancel_single_job(
&username,
Some(reason.clone()),
queued_job,
w_id,
tx,
db,
true,
)
.await
{
Ok((tx, _)) => {
if let Err(e) = tx.commit().await {
tracing::error!(
"Failed to commit cancel for orphaned tool job {}: {}",
job_id,
e
);
}
}
Err(e) => {
// warn not error: job may have completed between fetch and cancel (expected race)
tracing::warn!("Failed to force-cancel orphaned tool job {}: {}", job_id, e);
}
}
}
}