mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
* feat: eval datasets and standalone runs for reusable AI agents Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: agent eval drawer with case editor, runs and capture entry points Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: document AI agent eval datasets and standalone runs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: say how many eval cases the list is not showing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address review findings on eval datasets - keep an edited case's conversation and tool inputs: serde(flatten) silently drops Box<RawValue> fields, so the update payload is spelled out - remount the case editor per case so one case's turns cannot leak into another - require jobs:read / flow_conversations:read on the capture endpoints, which UserDB does not gate by token scope - take the dataset lock in create and update so a delete cannot be undone by a concurrent metadata write, and delete cases before metadata - load more cases beyond the first page, and stop capping the agent picker - record that the version stamp is taken at enqueue, not at resolution Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address round-2 review findings on eval datasets - block operators from dataset and case writes - pass the editor's operating workspace through the drawer and the capture request, instead of assuming the navigation workspace - discard superseded case-list responses so switching datasets cannot land the previous dataset's cases - reject a dataset without a case_id (or vice versa) rather than running an inline case under a dangling association - run unsaved edits inline instead of silently running the stored case - surface the API error body on a failed run - fetch dataset metadata concurrently when listing - $bindable() without a default on the optional open prop - correct the permission and enqueue-time-version wording in the docs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: run an untouched saved case by reference again The editor writes back keys the stored case omits, so comparing the raw objects reported every unedited case as edited: the run went inline and lost the dataset/case stamp its history depends on. Compare a normalized form, and pin it with a test. Also scope the history query to the drawer's workspace and drop superseded responses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: show a dataset's cases as a table, and fix round-4 review findings The case list showed one case at a time with no overview. It is now a table with the case, where it was captured from, and its last run — the last-run column is a single jobs query on the path stamp rather than a request per row. Review fixes in the same file: - keep the edit baseline on the selected case rather than looking it up in the loaded page, so a case beyond page 1 is not treated as unedited and run stale - release the loading state when a superseded case load returns early - reload every loaded page after a write instead of collapsing to page 1 - last remaining 'resolved to' wording in the version tooltip Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: run a dataset as an experiment, with scorers as runnables An experiment runs every case of a dataset against one subject and records the exact case set it executed, so a result set stays reproducible while the dataset keeps changing. Each case runs as its own small flow — the agent, then a step per scorer — so a case keeps the run stamp, history query and trajectory view a single run already has, and scorers need no orchestration of their own. Results are read back per step by node id rather than by walking a nested loop's status. A scorer is any runnable taking (input, output, expected): a script, a flow, or a reusable agent used as a judge. A judge is prompted with the case and the answer as one JSON message; a script or flow receives them as named arguments. Scores accept a bare number, a boolean or {score}. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: results table for an experiment, with scorer columns One row per case: status, the agent's answer, and a column per scorer, with the mean per scorer above the table and a link into each case's run for its trajectory. Averages skip cases a scorer produced no number for — counting a missing score as zero would read as a regression. The drawer's left pane becomes Cases / Results, and Results carries the scorer picker and Run dataset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: compare an experiment against a baseline Per-scorer deltas on each row and on the mean, and a filter down to the rows that regressed. Rows join by case id, so a case added after the baseline ran has no delta instead of counting as a change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address round-5 review findings on experiments - match scorers by label when diffing two experiments; joining by array position subtracted one scorer from another whenever the scorer sets differed - report a row's status from the case job, not the agent step, so a case whose scorer failed no longer reads as a success - delete a dataset's experiments with it: they hold copies of its cases, and a recreated dataset of the same path would have exposed them - select the experiment that Run dataset just started instead of leaving the table on the previous one - expected is scored now, so stop describing it as having no consumer Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address round-6 review findings on experiments - hold the dataset lock across an experiment launch, so a delete landing between reading the cases and writing the experiment cannot recreate the deleted dataset's inputs - match scorers between experiments on kind and path, not on label: labels default to a path's last segment, so f/a/quality and f/b/quality compared against each other - average mean deltas over the cases both runs scored; comparing each run's own average reported a regression from a case the baseline never ran, with no regressed row to point at - openapi: the row status is the job's, which is also canceled/skipped; runEval takes scorers; the update-case body no longer advertises source, which the handler deliberately ignores - record why the experiment prefix cannot reach a sibling dataset Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address round-7 review findings on experiments - release the dataset lock for the push loop and retake it for the write, re-checking the dataset still exists: holding it across the whole launch made every capture and case edit on that dataset 409 until the last job queued - assemble experiment results with bounded concurrency; a 100-case, 3-scorer experiment was 400 sequential lookups, each itself several queries - clear the baseline when it becomes the selected experiment, which was comparing a run against itself and reporting zero deltas - take the header mean over the same cases as its delta while comparing, so the two numbers beside each other describe the same set - a canceled or skipped case is no longer the same grey dot as a running one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address round-8 review findings on experiments - verify the dataset's identity, not just its existence, before recording an experiment: the path can be deleted and recreated during the push loop, and the experiment holds copies of the old dataset's cases - give the recording lock a longer budget than a case edit, since its jobs are already queued and giving up strands them, and say so when it fails - keep score lookups sequential within a case: nesting two bounded streams multiplied into 32 in-flight queries against a 50-connection pool - clear a baseline that no longer belongs to the loaded experiments, so switching datasets does not leave comparison mode on with nothing to compare - keep a scorer's own mean when the baseline never ran it, instead of blanking a column full of numbers - EvalCaseDraft.expected no longer claims nothing scores it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: do not trust an experiment's job ids, and require write to record one Experiment objects live in workspace object storage, which a script can write directly, and results are read on the unrestricted pool — so a forged experiment naming another flow job returned output the jobs API would have refused. Only jobs this server stamped with that experiment's id are read now. Also from round 9: - recording an experiment requires write on the dataset, not read: it persists into the dataset's namespace and its shared list - clear the results table when the selection changes and surface a failed load, instead of labelling the previous experiment's numbers as the new one's - a storage fault is no longer reported as a deleted dataset - the lock-timeout message at the recording site no longer says to retry, which would run the whole dataset again on top of the jobs already queued - ExperimentRow.status documents canceled and skipped Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: bind the experiment trust check to the requested dataset The previous check matched jobs on the experiment id alone, which the stored object supplies — so copying another dataset's experiment JSON under a readable key carried its jobs' output along with it. A job is now only read if it was stamped for this experiment *and* for the dataset the caller's read access was checked against, and an experiment that names a different dataset is not served from this key at all. Also from round 10: - add the .sqlx entry for that query; without it every SQLX_OFFLINE build failed - serve results over GET: as POST the route-scope middleware classified a read as ai_evals:write, locking read-only tokens out of their own results - clear the selected and baseline experiments synchronously when the dataset changes, so the previous dataset's id is not requested under the new one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address round-11 review findings on experiments and scorers - give scorers the whole case input, not just the message: an answer that came from attachments or a replayed conversation could not be judged on it - accept a judge's boolean and structured {score} answers, including stringified ones, and pin every documented scorer shape with a test - record an experiment for the cases that did launch when a later push fails, instead of leaving those jobs running with nothing to attribute them to - do not capture a preview parent's synthetic runnable_path as a host flow; the saved case could not be rerun - clear the case table before loading a dataset and surface a failed load, so a failure cannot leave the previous dataset's cases under the new name - keep the results table through a refresh of the same experiment - exclude flow-step jobs from the per-case last-run lookup - drop case sets from the experiment list, which is only used to pick a run - report a database failure at the recording lock as itself, not as contention Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address round-12 review findings on capture and run history - load flow_node.flow for flownode parents: an agent inside a deployed branch or loop captured without its agent, host flow or tool bindings - decide host_flow_path by whether the path resolves to a flow, not by job kind: excluding previews wholesale also dropped the flow editor's step test, whose path is real - page the per-case last-run lookup by created_before until the loaded cases are covered; one page of 200 reported older cases as never run - do not record an experiment when nothing launched - only attach the case input to a job when a scorer will read it - keep the case table through a save; only a different dataset clears it - drop the superseded duplicate comment on the score parser Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop refetching run history on every case write Reading the case list before the first await made the whole job-history query a dependency of it, so every save, delete and Load more refetched up to 1000 job rows and blanked the column. Read untracked instead. - an empty Last run cell now distinguishes never-ran from not-found-within the page bound, which the comment already claimed and the cell did not - reloading a dataset no longer replaces a populated table with a skeleton - keep the score-parser comment that describes every shape it handles Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: keep eval datasets in Postgres instead of object storage Datasets, cases and experiments become rows (`eval_dataset`, `eval_case`, `eval_experiment`, `eval_experiment_case`) rather than objects under a `wmill_eval_datasets/` prefix. What a run produced is still the job's: only case inputs and an experiment's case snapshot are stored. This removes the machinery the object store needed: - The advisory lock and the read-modify-write of a per-dataset JSONL. A case is a row, so there is nothing to serialize. - The launch-time identity check on the dataset. The foreign key makes a concurrent delete fail the transaction instead. - The trust guard on an experiment's job ids, which existed because a script can write workspace object storage directly and could forge an experiment naming somebody else's job. An experiment now chooses every job id and records itself before pushing anything, so a launch that dies partway leaves a recorded case whose job is missing rather than a running job nothing accounts for; cases that never reached the queue are removed again. Row-level security on `eval_dataset` is the authority on who may read or write a dataset, so `extra_perms` grants work and the rule is not mirrored in Rust. Cases and experiments carry a read policy derived from their dataset and no write policy: they are written on the unrestricted pool after the dataset row itself has been asked, with `SELECT ... FOR UPDATE`, whether the caller may write it. Cases are capped at 256 KiB each and 10 000 per dataset, refused rather than truncated. Attachments are S3 references, not inline bytes, so a case that approaches either cap is a mistake rather than a use case. Evals no longer need the `parquet` feature or a configured workspace object storage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style: align the eval drawer with the design system - Scorer chips are `Badge`s rather than a hand-rolled bordered span, and the section header is a `Label` with its tooltip, as are the case editor's fields (which also gets the label colour right). - The results table showed status as a coloured bullet, which says nothing to a colour-blind reader. It now carries the same icons the runs table uses, with the status as its accessible name. - Feedback colours move to the `-500` shades the brand guidelines name. - The conversation JSON error uses `TextInput`'s `error` prop for the border and the caption style for the message, as elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: author an expected answer, tags and attachments on a case Every scorer is handed `(input, output, expected)`, but nothing could produce an `expected` except a conversation capture: the case editor had no field for it and a captured run left it empty. So: - The editor gains Expected, Tags and a read-only list of the attachments a captured case carries. Expected is plain text, or JSON when the answer has structure. - Capturing from an AI agent run keeps what that run answered, which is the only moment a reference answer exists for free. The results table also laid itself out by content, so a long answer pushed the scores — the numbers the table exists for — off the edge of the pane. It is fixed-layout now, with the text columns bounded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: expected is captured from a run and can be authored Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: link a saved agent when inserting an ai agent step "AI Agent" in the step picker was a leaf that always created a blank step, so reusing a saved agent meant inserting a blank one, opening its step input and linking it there. It is a category now, like Flow and AI Sandbox, listing the workspace's `ai_agent` resources next to a blank option, filtered by the picker's own search. A picked agent produces a step that is already linked rather than one linked afterwards: `agent` set, no tools, and only the flow-local `user_message`/`user_attachments` transforms. Seeding the brain keys there would leave transforms a linked step never reads and that `AgentResourceBar` strips on its next link change. Each `on:new` forwarder rebuilds the insert detail field by field instead of spreading it, so a new field is dropped unless the forwarder names it. `agentPath` is typed on both `GraphEventHandlers.insert` and `FlowGraphV2`'s `onInsert` so the next one to forget it fails the check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: restore the link on cancel and simplify the agent bar Cancel on an agent edit forked the step into a standalone copy, which is the opposite of what the word means and needed a paragraph under the card to explain. It discards the edits and re-links the step now, leaving the agent untouched; diverging from an agent is Unlink's job, on the linked card. This flow's `tool_inputs` survive the round trip as overrides, so Cancel no longer folds them into the tools the way Unlink does. Linking a step to a saved agent happens in the step picker at insert time, so the bar's own resource picker is gone and "Save as agent" is the one action left. Its `+` button was a trap besides: it opened the generic resource form, where an agent would have to be written as raw JSON. The card itself was `surface-secondary`, the sections token, so in dark mode it was darker than the pane and read as a sunken well rather than an elevated card. It uses `surface-tertiary` as the brand table prescribes, its tool chips are `Badge`s, and the editing card no longer overflows the pane and clips its own buttons. The remaining tooltip follows the inline `Label` convention rather than sitting in a flex row whose gap stacked on the trigger's own margin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: rework the AI agent evals surface into one table Evals become a single pane: a dataset of cases, one column per scorer, one row per case, with the run being looked at chosen from the toolbar. Runs are permanent. Running the whole dataset opens one; running a single case records nothing at all — it is a job, and looking at what it did is not a claim that it belongs in the history. Its result and its scores sit over the row until they are saved as a run, which carries the cases that were not rerun and the scoring jobs themselves, so the number that is saved is the number that was looked at. A scorer is a runnable: a judge agent or a script, created in one click and edited in place. Scores carry a reason and per-assertion checks, shown on hover with a rescore button. What ran is always named. A run records the agent version, or — for a configuration that is not deployed — a hash of it, so a table can say that its numbers describe an agent that no longer exists: those rows dim and the table offers to rerun. An agent's draft can be run directly instead of the deployed value, and once those edits are deployed the runs that made them are recognised as that version. A step with no agent of its own is evaluable too, and saving it as an agent moves its history onto it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: keep an agent's in-progress edits on the agent Editing a linked agent forks it into the step, which is what makes the edits runnable there — but the agent is what is being edited, so that is where the unsaved state belongs. The edit is mirrored into the agent's own resource draft as it is made. It then survives leaving the flow, shows the agent as drafted wherever it appears, and is what evals run when asked to run the draft rather than what is deployed. Deploying or cancelling clears it; opening Edit without changing anything does not mark the agent as drafted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: shape the evals surface around a saved agent Evals hang off an `ai_agent` resource, so the surface is now only ever about one: the `draft` subject kind, the standalone-step subject and the move that carried a step's history onto a newly saved agent are gone. - A run is permanent and numbered per agent. Running a single case is a trial: it answers in the panel and never touches the table. - "Run scorers only" opens a run of its own that reuses the answers of the run you are looking at, so a scorer added later measures what already ran without calling the agent again. - A draft run whose configuration is later deployed is stamped, once, to the version it became, so its label stops reading `v23 + edits` forever. - A scorer can carry a pass threshold, read off the scores already recorded. - The table is the case, its answer and one number per scorer; datasets are created and edited in a drawer; a run that executed an earlier state of the current draft says so above the table, in one line. - Which agent a step is, whether it is being edited, and which version it is on is a strip above the step's tabs, because it is true of every tab. - Capturing a case from a step test or a conversation is dropped, and with it the `memory` override on a linked step that nothing set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: run past versions of an agent, and number versions per resource The evals home becomes one table of every run of the agent, whichever dataset each is of, with one badge per scorer. A list spanning datasets cannot hold every dataset's scorers to look a name up, so a score carries its name and kind with its number, and thresholds are joined in per run and column. Run now asks what to run: the latest agent, resolved when the run executes as a flow step does, any past version, or the unsaved edits. Pinning is a subject kind of its own, since a linked step resolves the resource live and inlining is the only way to run a version that is no longer current. Scorers move into the edit-dataset drawer. The column header over a run reports and nothing else: a run is permanent, and a control there that changed the columns would edit the past from the one place that must not. Adding one offers four ways rather than two, writing and reusing being different jobs, and both new kinds open with a summary filled in. Versions are numbered per resource. `resource_version.id` is one identity sequence for the whole table, so an agent saved nine times read v4 ... v24, and the gaps counted writes in workspaces the reader cannot see. The id stays how a version is addressed; the new number is what it is called, in the resource history drawer as well as here. It is assigned on write rather than counted on read because trimming past the cap and clearing a history both take the oldest rows, and counting the survivors would renumber a version a run already names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: read the dataset a remembered selection names Reopening the evals modal restored the last dataset from storage as a bare path, without reading the row it names. Every "is this already the one?" test compared against that selection, so all of them short-circuited and the dataset was never loaded: editing it opened a drawer with no summary, no scorers and no cases. The remembered path is now brought into context the same way any other choice is, and the tests compare against the dataset that is loaded rather than the one that is selected, so a selection can no longer stand for a read that did not happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: give dialogs a trail in their header A dialog deep enough to navigate had nowhere to say where you were: the header held a fixed title, and the way back was a control each body placed for itself, somewhere in a toolbar that moves with everything else the toolbar holds. The header is the one part of the surface that does not move, which is where the trail belongs. `Modal` takes an optional `trail` of levels below its title, rendered as a breadcrumb whose ancestors are the way back. Declarative on purpose: callers of this depth already hold the state that says where they are, so the dialog reads it rather than owning a stack they would have to push and pop in step with it. Escape follows the trail. Leaving a level is what someone deep in a dialog means by it, and closing the whole surface throws away the navigating they did to get there; at the root it closes as before. That only works if a dialog can tell it is the surface being addressed, so `Disposable` now answers `isTopmost()` and the dialog asks before acting: it keeps Escape for itself, so nothing else was arbitrating between it and a drawer opened from inside it, and both were acting on one key press. Evals is the first caller: its runs list is the root, a run is a level in it, and the back button that used to sit above the table is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: portal dialogs out of wherever they were opened from A dialog rendered in place inherits whatever the calling component happens to sit inside. One `transform`, `filter` or `overflow` anywhere above it makes its `fixed` positioning resolve against that ancestor instead of the viewport, and a surface meant to cover the app is then confined to a box it never asked for: the nav rail paints over it and its own edges are clipped. Drawers have always portalled for this reason. Dialogs only did so when an enclosing pane claimed them, and rendered in place otherwise, so the same screen could show a drawer over everything and a dialog trapped behind the nav. They now portal the same way: to the pane when one claims it, to `body` otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: make the dialog's title the first step of its trail The trail listed levels below the title, so a dialog one level deep read "Evals > All runs > Run 20 · v6": three steps for two places, the first two of them the same place under different names. The title is the root, so it is the root's own segment, and the trail a dialog is given is now the whole path with that segment at its head. Its height stopped moving too. A heading carries a line-height of its own, so a header holding only an h3 stood six pixels shorter than one holding segments as well, and the dialog's whole top edge stepped as you navigated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: sharpen the evals controls around where you are standing Each screen now offers what belongs to it. The list starts runs; a run is a record, so it offers only the one thing that acts on the record itself, which is measuring the answers it already stored. Starting a fresh run from inside one asked which agent and which dataset from the screen least about either, and scoring an existing run was offered from the list, where there is no run to score. Which run and what it is read against are one question asked twice, so they sit together rather than at opposite ends of a row. Choosing what to run is now a toggle over the two states worth naming, the draft and the saved agent, with every earlier version one click further: running an old version is deliberate, and a list made all three look alike. The draft is read when the dialog opens rather than taken from the caller's polled copy, which could be seconds behind an agent edited a moment ago and would leave the option out exactly when it is the reason for opening the dialog. The dataset field carries its path under it and its edit button on hover, as a resource picker does, so the closed field says what the open list said. Edits waiting on an agent are a "draft" here as everywhere else in Windmill, rather than "+ edits". The dialog runs an evaluation rather than "the agent", which is what it was already called everywhere it is recorded. An agent being edited keeps its evals button on a line of its own, clear of the decision to save or discard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: settle the evals controls on the patterns Windmill already has The version choice uses ToggleButtonMore, as the AI provider picker does: the two states worth naming stay in the group, the rest are behind the overflow menu, and the one you pick joins the group rather than appearing in a second control below it. The deployed one says which version it resolves to. A run offers nothing to start. Scoring an existing run again was the last thing left there, and it was one button explaining a distinction that the run and the dataset already make between them. The warning that a run executed an earlier draft is about the run on screen, so it goes when the run does rather than following you back to the list, and it sits against the table instead of inside a frame of its own. A dataset just created stays open for its scorers and cases: those are what a dataset is, they can only be added to one that exists, and closing on create sent you to find it again to add them. Scorer settings are a cog rather than a word, now that the row holds three actions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: close the gap in the version toggle and say what naming a dataset does The overflow trigger is not a pill, so the room it reserves showed as a gap between it and the button before it; it is pulled in by that much. The dataset field gets its clear button, which is also the slot the edit button is positioned against, so the two now sit where a resource picker puts them. Naming a new dataset said nothing about what happens next, and the drawer looked like it was missing the rest of itself. It says so instead: a scorer and a case both belong to a dataset, so there is nothing to attach either to until this one exists, and creating it leaves the drawer open on them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: choose a dataset's scorers while naming it A scorer is a reference to a runnable, not a child of the dataset, so it needs the dataset's name but not its row. The list is collected in the drawer while the dataset is being named and sent with the create, which already accepts one, so a dataset arrives holding the columns that were chosen for it rather than being made empty and then edited to hold them. Cases stay where they were: a case *is* a row of the dataset, so there is nothing for it to be a row of until one exists. The drawer says which of the two is which instead of leaving the screen looking like it is missing the rest of itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: level the version toggle and name the dataset in its own field The overflow trigger stands a row taller than a toggle button, so the group grew to its height and left the sunken background showing under every pill beside it. Every child of the group is the same height now, which is why the AI provider picker never had the band: it sizes them all alike. The dataset field says the summary with the path after it rather than carrying the path on a line below. The list stacks the two, which a one-line field cannot do, so it says both the other way round. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: tidy the evals forms and the run's own controls Picking a scorer that exists chooses between two sources rather than showing both: the ones already measuring something, and everything else in the workspace. The first list says what each is called with its path under it and what it already measures on the right, instead of three columns that were the same path truncated three ways whenever a scorer had no name of its own. A dataset's drawer says what it is for on the page rather than under an icon, and its summary is sized like the field beneath it. The run's own row lines up with the table under it, the warning above that table is spaced off the rule rather than sitting on it, and adding a case is gone from a run: a run is a record of cases that were answered, so curating them from it is editing what it measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: create a dataset holding the cases written for it Creating a dataset takes the cases to create it with, so one can be assembled in a single act instead of made empty and then filled in. The drawer holds them while the dataset is being named, gives them ids of its own to be edited by, and sends them with the create. Every case is checked before the dataset is written. `eval_case` grants users no write, so the rows cannot be inserted in the transaction that creates the dataset under the caller's own policies; validating first is what keeps "created holding these cases" from becoming "created, holding some of them", and the rows that do follow go in one transaction of their own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: name the button for what it opens, and say what each version is Starting an evaluation asks which state of the agent and which dataset, and both cost a provider bill, so a button that read as spending one on the way past was lying about the click. It opens something, and says so. Running one case from the panel keeps its own name and its play icon, because that one does run on click. The version options say what they are rather than what they are not: what a flow step would or would not run is a fact about somewhere else, and someone choosing what to evaluate is not standing in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: give the editing card two rows and mark evals as beta At the width of a step panel the card's one row wrapped: the line naming the agent, the line saying what saving does, and the two buttons deciding the edits' fate all fought for it. Deciding gets a row of its own, and evals sits against the line it is about, since evals of an agent being edited run the edits. Evals is named wherever it is offered. It read as a word in one state of the card and as an icon in the other, which is two things to recognise for one door. The dialog carries a beta badge against its own name, before any level below it: every way in lands there, so it is said once and stays put as you navigate. The version toggle spells out which is which. Both are the agent at v2 and the difference between them is the whole choice, so it is worth the width. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: name a new dataset, and lay the scorer's settings out like a step's inputs A new dataset arrives called "Dataset 1", which the path follows as it follows any summary: a dataset with none was one every table could only call by its path, and the two seeds are what the summary rule already produces. Scorer settings put each field's description between its label and its input, where a step's inputs put theirs, and its inputs are the size the rest of the drawer uses. The runnable behind the column is a link to it with its kind's icon, since it is a resource of its own and the one thing about it these fields cannot change. The line explaining that a pass line re-reads recorded scores went: the threshold is a number to set, and how it is applied is not a decision being made here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: curate a dataset in the drawer and save it in one act The drawer holds the cases while they are edited and writes them when it is saved: added, changed and dropped, whichever it is. Typing no longer writes, so a set is never half saved while someone is still deciding what is in it, and Save means the same thing whether the dataset exists yet or not. A case panel offers reading rather than acting. Running one case now and editing one from a run were the last two ways to change a record from the screen showing it, and the machinery behind the first went with it. The answer is rendered as the prose it is, under what it is: the case's result, whichever run is selected above it. The rest is what the run's table was doing to its own edges: a column name is clipped to its column rather than running into the next, the table squares off against an open panel, and that panel closes with the run it belonged to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: one border above a table, and a link to the run's job The row above the table drew a bottom border and the table draws its own top edge, so every table sat under two lines. The row keeps its spacing and the table keeps its edge. A column header no longer spins while its scores arrive: the cells under it are where the numbers are missing, and they say so themselves. The beta badge is the height of the word beside it rather than of the line it sits on. A run is one flow and therefore one job, so the run says where that job is: what it is doing, what it cost and what it logged are all there rather than reconstructed from the table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: stream scores as each scorer finishes, and show them per case A scorer runs after the agent inside the case's own iteration, so its verdict can be read as soon as its step is done. Waiting for the iteration to end held every column of a case back until the last of them finished, which is why answers arrived one at a time and scores all at once. Reading a job that is still running needs one guard: a module with nothing in it is a step that has not run, not one that produced nothing, and recording the second makes a failure that never goes away. The panel beside the table shows what each column made of the case and why. The reason a judge gave was stored and never shown, which is the half of a score that says anything. It stops repeating the question the header already asks, and a case still running reads as waiting rather than as an answer that says "Running". A run is a number beside a dataset, so the list puts the two together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: score a case with every scorer at once The scorers of a case read the answer and never each other, so they ran one after another for no reason: measuring a case now takes as long as its slowest column rather than as long as all of them. Each is a branch of its own, kept from failing the others, so a judge that errors costs its own column and no more. An iteration is three steps again — answer, payload, scores — rather than one per scorer, and each branch is named for the column it produces, so the graph of a run says which scorer did what instead of spelling out an id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: read a judge's score out of the JSON it nearly wrote A judge quoting the agent inside its own reason writes those quotes unescaped, which is invalid JSON and also the most ordinary sentence for it to produce. The whole verdict was being thrown away over it, so a column that had a number reported having none. The number and the reason are now read straight out of such text. Deliberately not a second JSON parser: it finds the two keys and takes what follows, which is what survives a quote in the middle of a sentence. A case still running says so with a spinner rather than with the word "Running" sitting where its answer goes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: ask a judge for a shape instead of trusting it to write one A new judge carries an output schema, so the provider holds it to `{score, reason}` rather than the prompt asking it to. Windmill already delivers a schema whichever way the model takes it, a tool for Claude and Bedrock and the native parameter elsewhere, so there is no list of models to keep here. An agent with no runs offers its first one where the first row would be, rather than from a toolbar above a table that has nothing in it. Starting a run no longer picks a dataset for you. It fell back to whichever came first, which on an agent that has never run means offering another agent's set as though it were the obvious one; and with no dataset at all it says so and offers the one move there is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: report a column that failed throughout, and hold the run dialog The runs overview dropped any column that produced no number, so a judge that failed on every case of a run vanished from the row and read as a column nobody had asked for. The aggregate now reports every column that has cells, with the count of the ones it failed on, and the badge says "failed" where there is nothing to average. A column with no cells at all is still left out: that one was added after the run and has nothing to say about it. Creating a dataset closes the drawer rather than turning it into an edit of what it just made: scorers and cases already ship with the create, so there is nothing left to stay open for. Reached from the run dialog, it gives the screen back with the new dataset selected, and the dialog keeps the version you had already chosen. Also: - the case panel's job link moves to the panel's own header, where its scope is: the job is the whole iteration, not the answer it sat over - one action in the scorer drawer's header, as its neighbours have. The reuse list picks rather than adds, and says which dataset each column already measures - adding a case is the last row of the list it lands in - the pane shows what it has read rather than an empty state it has not earned yet, and its rows say they open - the linked agent card loses a border it had inside another one * fix: keep the linked agent card's outline The card is a thing inside the step's inputs rather than a section of them, and the outline is what says so. Only the rule inside it goes: the detail it separates is already set apart by being detail. * refactor: fit the eval surface to the shipped design * feat: give a nested dialog a back control and the runs list its own moves Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: put a dialog's description under its title Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: fold a dialog's back control into the crumb it returns to Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: edit a dataset's cases as a table rather than a list beside a form Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: edit a dataset's cases in the grid the data tables are edited in Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: edit a grid cell of prose in place, and cap a dataset at one page Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: keep the cell editor's styles beside it, not in the vendored theme Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: keep an empty cell empty and cap the editor's growth Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: name the step that assembles a run for the scorers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: run the payload step natively, and say so when nothing serves that tag Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: report an answer as answered while its scorers are still running Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: let a scorer say a case is not one it measures Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: score the answer, and leave a case with no expected answer unmeasured Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: split the evals backend into modules Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: record what a run produced so it outlives its jobs * fix: read only the agent step's own tool jobs into the payload * fix: pin a run's configuration and give the judge the attachments * feat: write a dataset's cases in one transaction * chore: refresh the sqlx cache for the eval queries * fix: drop results a newer selection has superseded * fix: keep a draft the agent editor never opened on * feat: let a run record what it produced instead of waiting to be read * fix: serialize the replacements of a dataset's cases * fix: stop the poller from superseding a read slower than its interval * chore: refresh the sqlx cache * fix: keep a failed read from settling a cell as a case with no answer * fix: hold the case grid while its save is in flight * fix: keep a failed collect step from failing the run it recorded * chore: refresh the sqlx cache * fix: commit an open cell into the save that reads it * refactor: size the eval buttons with unifiedSize * docs: describe a run as the one flow it is * fix: show a run's recorded rows when part of it cannot be collected * refactor: size the remaining PR-added buttons with unifiedSize * fix: save the dataset name that was submitted, not the one typed after * fix: force an open cell into the save that was pressed for it * fix: refuse to score a run whose evidence could not be read * fix: hold one lock over a dataset's case count and its writes * fix: keep one unreadable run from costing the whole runs list * refactor: drop the banned bindable-default from the eval props * fix: hold the scorer controls while the dataset is written * fix: read only the caller's own draft of an agent * docs: say in the contract that a run pins its configuration * fix: say a scorer did not run rather than blaming a missing answer * feat: resume the agent draft you already had when you press Edit * refactor: build the trail and dataset controls from Button * fix: clear the open-cell flag when the drawer reopens * chore: refresh the sqlx cache * fix: read a run's configuration and its version from one snapshot * fix: refuse a dataset path or summary the column cannot hold * refactor: handle the agent draft the way the resource editor does * fix: run only a configuration the launch actually read * docs: bound dataset path and summary where they are submitted * fix: surface a stalled agent draft instead of claiming it is kept * fix: stop claiming a draft holds edits a failed write never sent * fix: word a missing score only once the run says whether the case answered * fix: let a breadcrumb crumb shrink so its truncation applies * docs: describe where an agent's unsaved edits live and what drops them * fix: keep harvesting scores when the run cannot yet word a missing one * fix: report a refused draft write the card was reading as a save * fix: drop the refused draft write when the server copy is taken instead * refactor: build the scorer and dataset pickers from the design system * fix: say what removing a scorer column actually does * fix: drop a refused draft write wherever the server copy is read * fix: let a picker row be as tall as the two lines it holds * docs: record what removing a scorer column does to recorded runs * fix: send a queued draft write before reopening, and drop only what it refuses * refactor: write the agent draft at commit points instead of mirroring keystrokes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: run an agent's edits from the step instead of keeping them as a draft Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: make the diff badge keyboard operable and refuse an edits run without its edits Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: drop the dataset icon from the scorer picker rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: size the evals buttons like the rest of windmill and call a run of edits edits Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: count a brain expression as an edit of the linked agent * fix: cap scorers per dataset and report a launched run as launched * fix: harvest scores in one read, refuse duplicate case ids, allow group paths * fix: mint scorer ids server-side, save a dataset edit in one request, check attachments * fix: write a dataset edit and its cases in one transaction * fix: atomic dataset create/edit, reset eval pane per agent, stable pending scorer ids * refactor: govern eval_case writes by RLS so a dataset edit is one transaction * fix: pin launch snapshot, order case locks, cap dataset size, guard stale load * fix: cap dataset bytes on single-case writes, reset run-dialog flag on load failure * feat: migrate eval datasets on username change, settle unspawned cases, drop unused case endpoints * fix: resolve scorer scripts as the caller and pin their hash; migrate scorer paths on rename * fix: bound a failed tool call's error to the payload truncation cap * fix: pin scorer hash as a hex string, reject missing judges, migrate eval authorship * fix: record an out-of-range scorer result as an error, not a score * fix: resolve judges in one caller-scoped read, pin deployed scripts, bound pass_if * fix: settle unspawned cases only when the run completes, and their score cells too * feat: reassign eval datasets and their path references when offboarding a user * fix: use the regex backreference in offboarding eval path rewrites * fix: register eval datasets in offboarding registries, keep resource-version param name * refactor: name the resource-version path param id, since it is the row id not the version * fix: validate dataset paths canonically, clone eval data on fork, surface eval load and launch failures * docs: note MCP tool results are not yet surfaced to eval scorers * fix: show the eval error state on any load failure, not only an empty dataset list * fix: preserve eval case order across a batched save Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjewUvXFEjNLLw7kxz41h * docs: scope the eval launch delete-safety guarantee to the assembly window Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjewUvXFEjNLLw7kxz41h * fix: only offer deployed scripts as eval scorers, drop unbuilt rescore claim Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjewUvXFEjNLLw7kxz41h * fix: enforce 0-1 scorer threshold in the settings drawer and clear stale eval load errors Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjewUvXFEjNLLw7kxz41h * fix: scope subject version/hash reads to the caller and keep a 0 pass threshold Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjewUvXFEjNLLw7kxz41h * fix: select the saved dataset when creating or renaming from the Run dialog Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjewUvXFEjNLLw7kxz41h * fix: gate eval dataset rename on path ownership, not just write access Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjewUvXFEjNLLw7kxz41h * fix: tolerate a malformed agent config when resolving the deployed label Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjewUvXFEjNLLw7kxz41h * refactor: trim eval code and comments, fix shared select and modal paths Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: drop the rename warning when editing an eval dataset path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: add eval dataset delete, keep summary on partial edits, settle resultless scorer cells Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cover parseThreshold and subjectLabel Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: hold dataset Save during a scorer write, derive draft_hash only from the carried draft Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2076 lines
81 KiB
Rust
2076 lines
81 KiB
Rust
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_oss::{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,
|
|
providers::{
|
|
create_chat_completions_query_builder, create_query_builder, is_chat_completions_only,
|
|
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::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::{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;
|
|
|
|
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()
|
|
}
|
|
|
|
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, .. } = 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 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
|
|
};
|
|
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
..
|
|
} = 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 the flow-local
|
|
// inputs (user_message/user_attachments) of its own; both stay rigid, so the one thing it may
|
|
// bind to this flow is the tools' inputs, overlaid from `tool_inputs` below.
|
|
let (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);
|
|
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 ["user_message", "user_attachments"] {
|
|
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)
|
|
};
|
|
|
|
// 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
|
|
};
|
|
|
|
// 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 Some(summary) = t.summary.as_ref().filter(|s| TOOL_NAME_REGEX.is_match(s)) else {
|
|
return Err(Error::internal_err(format!(
|
|
"Invalid tool name: {:?}",
|
|
t.summary
|
|
)));
|
|
};
|
|
|
|
// 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)?);
|
|
(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.clone(),
|
|
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()
|
|
};
|
|
|
|
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);
|
|
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;
|
|
|
|
// Determine if we're using manual messages (which bypasses memory)
|
|
let use_manual_messages = matches!(args.memory, Some(Memory::Manual { .. }));
|
|
|
|
// 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);
|
|
|
|
// Validate: at least one of memory with manual messages or user_message must be provided
|
|
if !use_manual_messages && !has_user_message {
|
|
return Err(Error::internal_err(
|
|
"Either 'memory' with manual messages or 'user_message' must be provided".to_string(),
|
|
));
|
|
}
|
|
|
|
let is_text_output = output_type == &OutputType::Text;
|
|
|
|
// Flow-level memory_id (from chat mode) takes precedence over step-level memory_id
|
|
let memory_id = flow_context
|
|
.flow_status
|
|
.as_ref()
|
|
.and_then(|fs| fs.memory_id)
|
|
.or_else(|| {
|
|
// Extract memory_id from Memory::Auto if present
|
|
match &args.memory {
|
|
Some(Memory::Auto { memory_id, .. }) => *memory_id,
|
|
_ => None,
|
|
}
|
|
});
|
|
|
|
// Load messages based on history mode
|
|
if matches!(output_type, OutputType::Text) {
|
|
match &args.memory {
|
|
Some(Memory::Manual { messages: manual_messages }) => {
|
|
// Use explicitly provided messages (bypass memory)
|
|
if !manual_messages.is_empty() {
|
|
messages.extend(manual_messages.clone());
|
|
}
|
|
}
|
|
Some(Memory::Auto { context_length, .. }) => {
|
|
// Auto mode: load from memory
|
|
if let Some(step_id) = effective_flow_step_id {
|
|
if let Some(memory_id) = memory_id {
|
|
// Read messages from memory
|
|
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,
|
|
*context_length,
|
|
);
|
|
messages.extend(messages_to_load);
|
|
}
|
|
Ok(None) => {}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
"Failed to read memory for step {}: {}",
|
|
step_id,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// 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 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 = args.streaming.unwrap_or(false);
|
|
*has_stream = user_wants_streaming && is_text_output;
|
|
|
|
let mut final_events_str = 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);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Handle AWS Bedrock provider specially using the official SDK
|
|
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);
|
|
// Use Bedrock SDK via dedicated query builder
|
|
windmill_ai::providers::bedrock::BedrockQueryBuilder::default()
|
|
.execute_request(
|
|
&messages,
|
|
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(),
|
|
credentials.aws_access_key_id.as_deref(),
|
|
credentials.aws_secret_access_key.as_deref(),
|
|
credentials.aws_session_token.as_deref(),
|
|
)
|
|
.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,
|
|
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()),
|
|
};
|
|
|
|
// 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, 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");
|
|
|
|
// 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
|
|
&& 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 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?
|
|
}
|
|
};
|
|
|
|
match parsed {
|
|
ParsedResponse::Text {
|
|
content: response_content,
|
|
tool_calls,
|
|
events_str,
|
|
annotations,
|
|
used_websearch,
|
|
usage,
|
|
} => {
|
|
// 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);
|
|
}
|
|
|
|
// 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(memory_id) = memory_id {
|
|
let agent_job_id = job.id;
|
|
let db_clone = db.clone();
|
|
let message_content = "Used websearch tool successfully".to_string();
|
|
let step_name = step_name.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = add_message_to_conversation(
|
|
&db_clone,
|
|
&memory_id,
|
|
Some(agent_job_id),
|
|
&message_content,
|
|
MessageType::Tool,
|
|
&step_name,
|
|
true,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
"Failed to add websearch tool message to conversation {}: {}",
|
|
memory_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(memory_id) = memory_id {
|
|
let agent_job_id = job.id;
|
|
let db_clone = db.clone();
|
|
let message_content = response_content.clone();
|
|
let step_name = step_name.clone();
|
|
|
|
// Spawn task because we do not need to wait for the result
|
|
tokio::spawn(async move {
|
|
if let Err(e) = add_message_to_conversation(
|
|
&db_clone,
|
|
&memory_id,
|
|
Some(agent_job_id),
|
|
&message_content,
|
|
MessageType::Assistant,
|
|
&step_name,
|
|
true,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
"Failed to add assistant message to conversation {}: {}",
|
|
memory_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: MaxIterPartialResult<'a>,
|
|
}
|
|
#[derive(serde::Serialize)]
|
|
struct MaxIterPartialResult<'a> {
|
|
messages: &'a [OpenAIMessage],
|
|
}
|
|
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: MaxIterPartialResult { messages: &messages },
|
|
})?,
|
|
));
|
|
}
|
|
|
|
messages.push(OpenAIMessage {
|
|
role: "assistant".to_string(),
|
|
tool_calls: Some(tool_calls.clone()),
|
|
..Default::default()
|
|
});
|
|
|
|
// 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,
|
|
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);
|
|
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(memory_id) = memory_id {
|
|
let agent_job_id = job.id;
|
|
let db_clone = db.clone();
|
|
|
|
// 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());
|
|
|
|
// Spawn task because we do not need to wait for the result
|
|
tokio::spawn(async move {
|
|
if let Err(e) = add_message_to_conversation(
|
|
&db_clone,
|
|
&memory_id,
|
|
Some(agent_job_id),
|
|
&message_content,
|
|
MessageType::Assistant,
|
|
&step_name,
|
|
true,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
"Failed to add assistant message to conversation {}: {}",
|
|
memory_id,
|
|
e
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Return early since image generation is complete
|
|
return Ok(content);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return the final result
|
|
let final_messages: Vec<Message> = messages
|
|
.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
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Persist complete conversation to memory at the end (only if in auto mode with context length)
|
|
// Skip memory persistence if using manual messages (bypass memory entirely)
|
|
// final_messages contains the complete history (old messages + new ones)
|
|
if matches!(output_type, OutputType::Text) && !use_manual_messages {
|
|
if let Some(Memory::Auto { context_length, .. }) = &args.memory {
|
|
if let Some(step_id) = effective_flow_step_id {
|
|
// Extract OpenAIMessages from final_messages
|
|
let all_messages: Vec<OpenAIMessage> =
|
|
final_messages.iter().map(|m| m.message.clone()).collect();
|
|
|
|
if !all_messages.is_empty() {
|
|
let messages_to_persist = prepare_auto_memory_messages_for_persistence(
|
|
&all_messages,
|
|
*context_length,
|
|
);
|
|
|
|
if let Some(memory_id) = memory_id {
|
|
if let Err(e) = write_to_memory(
|
|
db,
|
|
&job.workspace_id,
|
|
memory_id,
|
|
step_id,
|
|
&messages_to_persist,
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
"Failed to persist {} messages to memory for step {}: {}",
|
|
messages_to_persist.len(),
|
|
step_id,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
},
|
|
usage: if final_usage.as_ref().map(|u| u.is_empty()).unwrap_or(true) {
|
|
None
|
|
} else {
|
|
final_usage
|
|
},
|
|
}))
|
|
}
|
|
|
|
#[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()
|
|
}
|
|
}
|
|
|
|
/// 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 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);
|
|
}
|
|
}
|
|
}
|
|
}
|