Merge pull request #887 from fabro-sh/bind-run-goal-into-petri

Bind the run's resolved goal into Petri's check
This commit is contained in:
Bryan Helmkamp 2026-09-21 08:07:55 -04:00 • committed by GitHub
commit 3c6d529a88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 243 additions and 34 deletions

30
Cargo.lock generated
View file

@ -5177,7 +5177,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petri-attractor-steps"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"async-trait",
"globset",
@ -5208,7 +5208,7 @@ dependencies = [
[[package]]
name = "petri-driver"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"async-trait",
"getrandom 0.3.4",
@ -5228,7 +5228,7 @@ dependencies = [
[[package]]
name = "petri-engine"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"petri-ir",
"serde",
@ -5240,7 +5240,7 @@ dependencies = [
[[package]]
name = "petri-execution"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"async-trait",
"petri-driver",
@ -5264,7 +5264,7 @@ dependencies = [
[[package]]
name = "petri-executor"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"async-trait",
"libc",
@ -5279,7 +5279,7 @@ dependencies = [
[[package]]
name = "petri-executor-sandbox"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"async-trait",
"petri-executor",
@ -5301,7 +5301,7 @@ dependencies = [
[[package]]
name = "petri-frontend"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"marked-yaml",
"petri-ir",
@ -5315,7 +5315,7 @@ dependencies = [
[[package]]
name = "petri-frontend-attractor"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"minijinja",
"petri-frontend",
@ -5332,7 +5332,7 @@ dependencies = [
[[package]]
name = "petri-frontend-fabro"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"petri-frontend",
"petri-frontend-attractor",
@ -5348,7 +5348,7 @@ dependencies = [
[[package]]
name = "petri-frontend-native"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"petri-frontend",
"petri-ir",
@ -5359,7 +5359,7 @@ dependencies = [
[[package]]
name = "petri-ir"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"regex",
"serde",
@ -5372,7 +5372,7 @@ dependencies = [
[[package]]
name = "petri-runtime"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"async-trait",
"petri-driver",
@ -5393,7 +5393,7 @@ dependencies = [
[[package]]
name = "petri-steps"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"async-trait",
"petri-executor",
@ -5409,7 +5409,7 @@ dependencies = [
[[package]]
name = "petri-store"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"async-trait",
"getrandom 0.3.4",
@ -5424,7 +5424,7 @@ dependencies = [
[[package]]
name = "petri-testkit"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9d5171558115d94e867af01ec6d1938e0f636e12#9d5171558115d94e867af01ec6d1938e0f636e12"
source = "git+https://github.com/lithoscomputer/petri.git?rev=911dbb5f24099f0f616fc6b5638318f956ae22c9#911dbb5f24099f0f616fc6b5638318f956ae22c9"
dependencies = [
"async-trait",
"petri-driver",

View file

@ -118,13 +118,13 @@ pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "67c
# one copy of each. Only `fabro-petri` and `fabro-dot` (the DOT parser alone)
# may depend on these packages; the keys carry the `petri_` prefix so the crate
# names say where they come from.
petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-runtime" }
petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-execution" }
petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-store" }
petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-attractor-steps" }
petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-frontend-attractor" }
petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-frontend-fabro" }
petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-testkit" }
petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "911dbb5f24099f0f616fc6b5638318f956ae22c9", package = "petri-runtime" }
petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "911dbb5f24099f0f616fc6b5638318f956ae22c9", package = "petri-execution" }
petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "911dbb5f24099f0f616fc6b5638318f956ae22c9", package = "petri-store" }
petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "911dbb5f24099f0f616fc6b5638318f956ae22c9", package = "petri-attractor-steps" }
petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "911dbb5f24099f0f616fc6b5638318f956ae22c9", package = "petri-frontend-attractor" }
petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "911dbb5f24099f0f616fc6b5638318f956ae22c9", package = "petri-frontend-fabro" }
petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "911dbb5f24099f0f616fc6b5638318f956ae22c9", package = "petri-testkit" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

View file

@ -17,6 +17,7 @@ use fabro_llm::selection;
use fabro_petri::check::{self, Admitted, Bundle, CheckError, CheckRequest, Diagnostic, Launch};
use fabro_petri::runtime::RuntimeSpec;
use fabro_types::diagnostic::{Diagnostic as FabroDiagnostic, Severity};
use fabro_types::settings::run::RunGoal;
use fabro_types::{ManifestPath, WorkflowSettings};
use fabro_workflow::Error as WorkflowError;
use fabro_workflow::workflow_bundle::WorkflowBundle;
@ -26,11 +27,11 @@ use lithos_llm::catalog::ProviderId;
pub(crate) const NO_READY_PROVIDER_RULE: &str = "fabro.model.no_ready_provider";
/// The launch Fabro binds around the settings: the run's model and provider
/// below them, and the environment the run selected above them. When the
/// settings name neither model nor provider, the default offering of the
/// eligible providers is bound as the launch model alone: a node that
/// names no model runs on it, and a node that names a model the catalog
/// lacks stays unqualified, so Petri's admission refuses it.
/// below them, and the environment the run selected and the goal the run
/// resolved above them. When the settings name neither model nor provider, the
/// default offering of the eligible providers is bound as the launch model
/// alone: a node that names no model runs on it, and a node that names a model
/// the catalog lacks stays unqualified, so Petri's admission refuses it.
pub(crate) fn launch(
catalog: &Catalog,
settings: &WorkflowSettings,
@ -51,10 +52,31 @@ pub(crate) fn launch(
model,
provider: settings.run.model.provider.clone(),
environment: environment.map(str::to_owned),
goal: launch_goal(settings),
repository,
}
}
/// The goal the run resolved, for Petri to bind over the bundle's layers
/// and the graph's own `goal`: the settings' inline `run.goal`, which the
/// create path has layered (an intent's override over the workflow layer
/// over the server's defaults) and whose workflow-layer `file` form is
/// inlined before layering. A `file` form that survives layering (a server
/// default) is left to the bundle's own `[run.goal]`, which Petri reads
/// itself; the text is not read here, away from the run's working
/// directory.
#[expect(
clippy::disallowed_methods,
reason = "goal text passes through in source form, as `materialize_admitted_run` displays it; \
Petri renders `{{ inputs.* }}` and `{{ vars.* }}` in it as it renders `[run] goal`"
)]
fn launch_goal(settings: &WorkflowSettings) -> Option<String> {
match settings.run.goal.as_ref()? {
RunGoal::Inline(text) => Some(text.as_source()),
RunGoal::File(_) => None,
}
}
/// The launch with no catalog to pick a default from: what the settings
/// name, for a check away from the server.
pub(crate) fn launch_without_catalog(settings: &WorkflowSettings) -> Launch {
@ -62,6 +84,7 @@ pub(crate) fn launch_without_catalog(settings: &WorkflowSettings) -> Launch {
model: settings.run.model.name.clone(),
provider: settings.run.model.provider.clone(),
environment: None,
goal: launch_goal(settings),
repository: None,
}
}

View file

@ -1631,3 +1631,121 @@ async fn a_bundle_naming_a_catalog_mcp_server_lists_its_tools_to_the_model() {
"the session lists the catalog server's tool under the reference's name: {tools:?}"
);
}
/// The hello bundle's agent stage, run with the given version files and an
/// optional intent goal override, to completion: the run's id, the twin's
/// request-log namespace and the server state.
async fn run_hello_agent(
files: &[(&str, &str)],
goal: Option<&str>,
) -> (Arc<AppState>, axum::Router, String, String) {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let twin = twin_openai().await;
let namespace = format!(
"{}::{}::{}",
module_path!(),
line!(),
goal.map_or("file", |_| "override")
);
TwinScenarios::new(&namespace)
.scenario(TwinScenario::responses(OPENAI_MODEL).text("A limerick, added."))
.scenario(TwinScenario::responses(OPENAI_MODEL).text("A limerick, added."))
.load(twin)
.await;
let settings = test_settings();
let state = TestAppStateBuilder::new()
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
.max_concurrent_runs(5)
.in_process_execution()
.llm_overlay(llm_overlay_with_provider_base_url(
"openai",
twin.base_url.clone(),
))
.vault_entries([(EnvVars::OPENAI_API_KEY, namespace.clone())])
.build();
let app = test_app_with_scheduler(Arc::clone(&state));
let version_id = register_version(&app, files).await;
let mut intent = intent(&version_id, workspace.path());
intent["args"]["model"] = serde_json::json!(OPENAI_MODEL);
if let Some(goal) = goal {
intent["goal"] = serde_json::json!(goal);
}
let run_id = create_and_start_run_from_intent(&app, intent).await;
let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
let run = run_json(&app, &run_id).await;
assert_eq!(status, "succeeded", "run: {run}");
// The workspace outlives the run: the run's record names it.
std::mem::forget(workspace);
(state, app, namespace, run_id)
}
/// The goal the run shows is the goal its stages execute with: `GET
/// /runs/{id}` names it, Petri's admitted graph carries it, and the agent
/// stage's prompt to the model opens with it in place of the graph's own.
async fn assert_run_goal(app: &axum::Router, namespace: &str, run_id: &str, goal: &str) {
let run = run_json(app, run_id).await;
assert_eq!(run["goal"], goal, "the run shows the goal: {run}");
let graph = admitted_root_graph(app, run_id).await;
assert_eq!(
graph["params"]["goal"], goal,
"Petri admitted the run's goal: {}",
graph["params"]
);
let logs = twin_openai().await.request_logs(namespace).await;
let prompt = logs["requests"]
.as_array()
.expect("twin request logs are an array")
.iter()
.filter_map(|request| request["input_text"].as_str())
.find(|input| input.contains("Add a haiku to the README"))
.unwrap_or_else(|| panic!("the agent stage's prompt reached the twin, got {logs}"));
assert!(
prompt.contains(goal),
"the agent's prompt carries the run's goal, got {prompt}"
);
assert!(
!prompt.contains("Say hello and demonstrate a basic Fabro workflow"),
"the graph's own goal is replaced, got {prompt}"
);
}
/// An intent's goal override is bound into Petri's check, so the agent
/// stages execute with the goal the run shows, not the workflow's own.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_goal_override_is_the_goal_the_stages_execute_with() {
const GOAL: &str = "Add a limerick to the README instead of a haiku";
if host_plugin().is_none() {
return;
}
let [(workflow_path, workflow), (settings_path, settings)] = hello_files();
let (_state, app, namespace, run_id) = run_hello_agent(
&[(workflow_path, &workflow), (settings_path, &settings)],
Some(GOAL),
)
.await;
assert_run_goal(&app, &namespace, &run_id, GOAL).await;
}
/// A `[run.goal] file` layer in the bundle's `workflow.toml` is the run's
/// goal the same way: the file's text is what the run shows and what the
/// agent stage executes with.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_run_goal_file_layer_is_the_goal_the_stages_execute_with() {
const GOAL: &str = "Write a limerick about workflow engines into the README";
if host_plugin().is_none() {
return;
}
let [(workflow_path, workflow), _] = hello_files();
let settings =
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run.goal]\nfile = \"goal.md\"\n";
let (_state, app, namespace, run_id) = run_hello_agent(
&[
(workflow_path, &workflow),
("workflow.toml", settings),
("goal.md", GOAL),
],
None,
)
.await;
assert_run_goal(&app, &namespace, &run_id, GOAL).await;
}

View file

@ -13,11 +13,12 @@
//! The launch binds the compile variables the Fabro frontend reads:
//! `petri.launch_model` and `petri.launch_provider` as the model default
//! below every file layer, `petri.launch_environment` as the environment
//! the run selected over every file layer, and `petri.repository` as the
//! repository the root `start` stage checks out. A caller with no local
//! repository binds `null`, and the run starts from an empty workspace. The
//! server's run variables (`{{ vars.NAME }}`) are bound as compile
//! variables beside them.
//! the run selected over every file layer, `petri.launch_goal` as the goal
//! the run resolved over every file layer and the graph's own, and
//! `petri.repository` as the repository the root `start` stage checks out. A
//! caller with no local repository binds `null`, and the run starts from an
//! empty workspace. The server's run variables (`{{ vars.NAME }}`) are bound as
//! compile variables beside them.
use std::collections::BTreeMap;
use std::path::PathBuf;
@ -25,8 +26,8 @@ use std::path::PathBuf;
use petri_frontend_attractor::kinds::{AGENT_KIND, PROMPT_KIND};
use petri_runtime::LoadError;
use petri_runtime::frontend::{
self, CompileInputs, LAUNCH_ENVIRONMENT_VAR, LAUNCH_MODEL_VAR, LAUNCH_PROVIDER_VAR, MapFiles,
REPOSITORY_VAR, Severity,
self, CompileInputs, LAUNCH_ENVIRONMENT_VAR, LAUNCH_GOAL_VAR, LAUNCH_MODEL_VAR,
LAUNCH_PROVIDER_VAR, MapFiles, REPOSITORY_VAR, Severity,
};
use petri_runtime::ir::Graph;
use serde::{Deserialize, Serialize};
@ -75,6 +76,11 @@ pub struct Launch {
/// selection overrides the bundle in Fabro's own resolution; `None`
/// leaves the layers to select.
pub environment: Option<String>,
/// The goal the run resolved (the intent's override, else the settings'
/// `[run] goal` from any layer), over the bundle's `[run] goal` and the
/// graph's own `goal`, so the stages execute with the goal the run
/// shows; `None` leaves the bundle's layers and the graph to state it.
pub goal: Option<String>,
/// The local repository the root `start` stage checks out into the
/// workspace; `None` starts the run from an empty workspace.
pub repository: Option<PathBuf>,
@ -219,6 +225,15 @@ fn compile_inputs(
Value::String(environment.clone()),
);
}
if let Some(goal) = launch
.goal
.as_deref()
.filter(|goal| !goal.trim().is_empty())
{
compile
.vars
.insert(LAUNCH_GOAL_VAR.into(), Value::String(goal.to_owned()));
}
// `Runtime::check_source` uses the inputs as given, so the repository
// is the host's to bind: the launch's path, or `null` for a run that
// starts from an empty workspace.

View file

@ -142,6 +142,7 @@ async fn a_launch_binds_the_repository_and_the_model_default() {
model: Some("gpt-5.4".to_string()),
provider: None,
environment: None,
goal: None,
repository: Some(repository.path().to_path_buf()),
},
runtime: RuntimeSpec::default(),
@ -367,3 +368,55 @@ fn an_unknown_environment_is_refused_and_the_launch_selects_over_the_bundle() {
let admitted = check::check(&selected).expect("the launch's selection admits");
assert_eq!(admitted.graph.params["fabro.environment"]["id"], "local");
}
/// The goal the launch binds (the run's resolved goal: an intent's override,
/// else the settings' `[run] goal`) is the goal Petri admits over the
/// bundle's own `[run] goal` and the graph's `goal`: the admitted graph's
/// `goal` parameter carries it, and so does the agent stage's config, which
/// is the goal its prompt is assembled from.
#[tokio::test]
async fn a_launch_goal_replaces_the_graphs_goal_on_the_admitted_graph_and_its_stages() {
const AGENT_WORKFLOW: &str = r#"digraph Agent {
graph [goal="The graph's goal"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Do the work"]
start -> work -> exit
}"#;
let settings = format!("{SETTINGS}[run]\ngoal = \"The bundle's goal\"\n");
let goal_of = |launch: Launch| {
let request = CheckRequest {
launch,
..request(
bundle(&[
("workflow.fabro", AGENT_WORKFLOW),
("workflow.toml", &settings),
]),
RuntimeSpec::default(),
)
};
let admitted = check::check(&request).expect("the agent bundle is admitted");
let stage = admitted
.graph
.body
.nodes
.iter()
.find(|node| node.name == "work")
.expect("the agent stage is admitted");
assert_eq!(
stage.step.config["goal"], admitted.graph.params["goal"],
"the stage executes with the run's goal"
);
admitted.graph.params["goal"].clone()
};
assert_eq!(
goal_of(Launch {
goal: Some("The run's goal override".to_string()),
..Launch::default()
}),
"The run's goal override"
);
// Without a launch goal, the bundle's `[run] goal` stands over the graph's.
assert_eq!(goal_of(Launch::default()), "The bundle's goal");
}